【Java自学】六、面向对象(下)
除前一章所介绍的关于类、对象的基本语法之外,本章将会继续介绍Java 面向对象的特性。Java为8 个基本类型提供了对应的包装类, 通过这些包装类可以把8 个基本类型的值包装成对象使用, JDKl. 5 提供了自动装箱和自动拆箱功能,允许把基本类型值直接赋给对应的包装类引用变量,也允许把包装类对象直接赋给对应的基本类型变量。
Java 提供了final 关键字来修饰变量、方法和类,系统不允许为final 变量重新赋值,子类不允许覆盖父类的final 方法, fmal 类不能派生子类。通过使用final 关键字, 允许Java 实现不可变类,不可变类会让系统更加安全。
abstract 和interface 两个关键字分别用于定义抽象类和接口, 抽象类和接口都是从多个子类中抽象出来的共同特征。但抽象类主要作为多个类的模板,而接口则定义了多类应该遵守的规范。Lambda 表达式是Java 8 的重要更新, 本章将会详细介绍Lambda 表达式的相关内容。enum 关键宇用于创建枚举类,枚举类是一种不能自由创建对象的类,枚举类的对象在定义类时己经固定下来。枚举类特别适合定义像行星、季节这样的类,它们能创建的实例是有限且确定的。
本章将进一步介绍对象在内存中的运行机制, 并深入介绍对象的几种引用方式,以及垃圾回收机制如何处理具有不同引用的对象,并详细介绍如何使用Jar 命令来创建JAR 包。
6.1 Java 8 增强的包装类
Java 是面向对象的编程语言, 但它也包含了8 种基本数据类型,这8 种基本数据类型不支持面向对象的编程机制,基本数据类型的数据也不具备"对象"的特性: 没有成员变量、方法可以被调用。Java之所以提供这8 种基本数据类型, 主要是为了照顾程序员的传统习惯。
这8 种基本数据类型带来了一定的方便性,例如可以进行简单、有效的常规数据处理。但在某些时候, 基本数据类型会有一些制约,例如所有引用类型的变量都继承了Obj ect 类, 都可当成Object 类型变量使用。但基本数据类型的变量就不可以, 如果有个方法需要Object 类型的参数,但实际需要的值却是2 、3 等数值,这可能就比较难以处理。
为了解决8 种基本数据类型的变量不能当成Obj ect 类型变量使用的问题, Java 提供了包装类( Wrapper Class) 的概念, 为8 种基本数据类型分别定义了相应的引用类型, 并称之为基本数据类型的包装类。
从表6 . 1 可以看出, 除int 和char 有点例外之外,其他的基本数据类型对应的包装类都是将其首字母大写即可。
从图6.1 中可以看出, Java 提供的基本类型变量和包装类对象之间的转换有点烦琐, 但从JDK l.5之后这种烦琐就消除了, JDK l. 5 提供了自动装箱(Autoboxing ) 和自动拆箱( AutoUnboxing ) 功能。所谓自动装箱, 就是可以把一个基本类型变量直接赋给对应的包装类变量, 或者赋给Object 变量(Object是所有类的父类, 子类对象可以直接赋给父类变量) ; 自动拆箱则与之相反,允许直接把包装类对象直接赋给一个对应的基本类型变量。
下面程序示范了自动装箱和自动拆箱的用法。
public class AutoBoxingUnboxing { public static void main(String[] args) { // 直接把一个基本类型变量赋给Integer对象 Integer inObj = 5; // 直接把一个boolean类型变量赋给一个Object类型的变量 Object boolObj = true; // 直接把一个Integer对象赋给int类型的变量 int it = inObj; if (boolObj instanceof Boolean) { // 先把Object对象强制类型转换为Boolean类型,再赋给boolean变量 boolean b = (Boolean)boolObj; System.out.println(b); } } }
当JDK 提供了自动装箱和自动拆箱功能后, 大大简化了基本类型变量和包装类对象之间的转换过程。值得指出的是, 进行自动装箱和自动拆箱时必须注意类型匹配,例如Integer 只能自动拆箱成int类型变量, 不要试图拆箱成boolean 类型变量: 与之类似的是, int 类型变量只能自动装箱成lnteger 对象(即使赋给Object 类型变量,那也只是利用了Java 的向上自动转型特性) ,不要试图装箱成Boolean对象。
借助于包装类的帮助, 再加上JDK 1 .5 提供的自动装箱、自动拆箱功能,开发者可以把基本类型的
变量"近似"地当成对象使用(所有装箱、拆箱过程都由系统自动完成,无须程序员理会) ;反过来,
开发者也可以把包装类的实例近似地当成基本类型的变量使用。
除此之外,包装类还可实现基本类型变量和字符串之间的转换。把字符串类型的值转换为基本类型的值有两种方式。
- 利用包装类提供的parseXxx(String s)静态方法(除Character之外的所有包装类都提供了该方法)。
- 利用包装类提供的valueOf(String s)静态方法。
String 类也提供了多个重载valueOf()方法,用于将基本类型变量转换成字符串,下面程序示范了这种转换关系。
public class Primitive2String { public static void main(String[] args) { String intStr = "123"; // 把一个特定字符串转换成int变量 int it1 = Integer.parseInt(intStr); int it2 = Integer.valueOf(intStr); System.out.println(it2); String floatStr = "4.56"; // 把一个特定字符串转换成float变量 float ft1 = Float.parseFloat(floatStr); float ft2 = Float.valueOf(floatStr); System.out.println(ft2); // 把一个float变量转换成String变量 String ftStr = String.valueOf(2.345f); System.out.println(ftStr); // 把一个double变量转换成String变量 String dbStr = String.valueOf(3.344); System.out.println(dbStr); // 把一个boolean变量转换成String变量 String boolStr = String.valueOf(true); System.out.println(boolStr.toUpperCase()); } }
如果希望把基本类型变量转换成字符串,还有一种更简单的方法: 将基本类型变量和""进行连接运算,系统会自动把基本类型变量转换成字符串。例如下面代码:
// intStr 的值为" 5" String intStr = 5 + "";
此处要指出的是,虽然包装类型的变量是引用数据类型,但包装类的实例可以与数值类型的值进行比较, 这种比较是直接取出包装类实例所包装的数值来进行比较的。
public class WrapperClassCompare { public static void main(String[] args) { Integer a = Integer.valueOf(6); // 输出true System.out.println("6的包装类实例是否大于5.0" + (a > 5.0)); System.out.println("比较2个包装类的实例是否相等:" + (Integer.valueOf(2) == Integer.valueOf(2))); // 输出false // 通过自动装箱,允许把基本类型值赋值给包装类的实例 Integer ina = 2; Integer inb = 2; System.out.println("两个2自动装箱后是否相等:" + (ina == inb)); // 输出true Integer biga = 128; Integer bigb = 128; System.out.println("两个128自动装箱后是否相等:" + (biga == bigb)); // 输出false } }
上面程序让人比较费解:同样是两个int 类型的数值自动装箱成Integer 实例后,如果是两个2 自动装箱后就相等;但如果是两个128 自动装箱后就不相等,这是为什么呢?这与Java 的Integer 类的设计有关,查看Java 系统中j ava.lang.Integer 类的源代码,如下所示。
//定义一个长度为256 的Integer 数组 static final Integer[] cache =new Integer[-( - 128) + 127 + 1]; static { //执行初始化,创建-128 到127 的工nteger 实例,并放入cache 数组中 for(int i = 0; i < cache . length ; i++) cache[i] = new Integer(i - 128) ; }
从上面代码可以看出, 系统把一个一128~ 127 之间的整数自动装箱成Integer 实例, 并放入了一个名为cache 的数组中缓存起来。如果以后把一个一12 8~ 127 之间的整数自动装箱成一个Integer 实例时, 实际上是直接指向对应的数组元素,因此一128~ 127 之间的同一个整数自动装箱成Integer 实例时,永远都是引用cache 数组的同一个数组元素,所以它们全部相等; 但每次把一个不在一128~127 范围内的整数自动装箱成Integer 实例时,系统总是重新创建一个Integer 实例,所以出现程序中的运行结果。
Java 7 增强了包装类的功能, Java 7 为所有的包装类都提供了一个静态的compare(xxx val1 , xxx val2)方法,这样开发者就可以通过包装类提供的compare(xxx val1, xxx val2)方法来比较两个基本类型值的大小,包括比较两个boolean 类型值,两个boolean 类型值进行比较时, true > false 。例如如下代码:
System.out.print1n(Boo1ean.compare(true , fa1se));// 输出1 System.out.print1η(Boo1ean.compare(true , true));// 输出0 System.out.print1n(Boo1ean.compare(fa1se , true)) ;// 输出-1
不仅如此, Java 7 还为Character 包装类增加了大量的工具方法来对一个字符进行判断。关于Character 中可用的方法请参考Character 的API 文档。
Java 8 再次增强了这些包装类的功能,其中一个重要的增强就是支持无符号算术运算。Java 8 为整型包装类增加了支持无符号运算的方法。Java 8 为Integer、Long 增加了如下方法。
- static String toUnsignedString(int/long i): 该方法将指定int 或long 型整数转换为无符号整数对应的字符串。
- static String toUnsignedString(int i/long,int radix): 该方法将指定int 或long 型整数转换为指定进制的无符号整数对应的宇符串。
- static xxx parseUnsignedXxx(String s): 该方法将指定字符串解析成无符号整数。当调用类为Integer 时, xxx 代表int; 当调用类是Long 时, xxx 代表long 。
- static xxx parseUnsignedXxx(String s, int radix): 该方法将指定字符串按指定进制解析成无符号整数。当调用类为Integer 时, xxx 代表int; 当调用类是Long 时, xxx 代表long 。
- static int compareUnsigned(xxx x, xxx y): 该方法将x 、y 两个整数转换为无符号整数后比较大小。当调用类为Integer 时, xxx 代表int; 当调用类是Long 时, xxx 代表long 。
- static long divideUnsigned(long divide时, long divisor): 该方法将x 、y 两个整数转换为无符号整数后计算它们相除的商。当调用类为Integer 肘, xxx 代表mt; 当调用类是Long 时, xxx 代表long 。
- static long remainderUnsigned(long dividend, long divisor): 该方法将x 、y 两个整数转换为无符号整数后计算它们相除的余数。当调用类为Integer 时, xxx 代表mt; 当调用类是Long 时, xxx代表long 。
Java 8 还为Byte、Short 增加了toUnsignedInt(xxx x) 、toUnsignedLong(yyy x)两个方法,这两个方法用于将指定byte 或short 类型的变量或值转换成无符号的int 或long 值。
下面程序示范了这些包装类的无符号算术运算功能。
public class UnsignedTest { public static void main(String[] args) { byte b = -3; // 将byte类型的-3转换为无符号整数。 System.out.println("byte类型的-3对应的无符号整数:" + Byte.toUnsignedInt(b)); // 输出253 // 指定使用16进制解析无符号整数 int val = Integer.parseUnsignedInt("ab", 16); System.out.println(val); // 输出171 // 将-12转换为无符号int型,然后转换为16进制的字符串 System.out.println(Integer.toUnsignedString(-12 , 16)); // 输出fffffff4 // 将两个数转换为无符号整数后相除 System.out.println(Integer.divideUnsigned(-2, 3)); // 将两个数转换为无符号整数相除后求余 System.out.println(Integer.remainderUnsigned(-2, 7)); } }
6.2 处理对象
Java 对象都是Object 类的实例,都可直接调用该类中定义的方法, 这些方法提供了处理Java 对象的通用方法。
6.2.1 打印对象和toString 方法
toString()方法是Object 类里的一个实例方法,所有的Java 类都是Object 类的子类,因此所有的Java对象都具有toString()方法。
不仅如此,所有的Java 对象都可以和字符串进行连接运算,当Java 对象和字符串进行连接运算时,系统自动调用Java 对象toString()方法的返回值和字符串进行连接运算,即下面两行代码的结果也完全相同。
String pStr = p + "";
String pStr = p . toString() + "";
toString()方法是一个非常特殊的方法,它是一个"自我描述"方法,该方法通常用于实现这样一个功能:当程序员直接打印该对象时,系统将会输出该对象的"自我描述"信息,用以告诉外界该对象具有的状态信息。
Object 类提供的toString()方法总是返回该对象实现类的"类名+@+ hashCode " 值,这个返回值并不能真正实现"自我描述"的功能,因此如果用户需要自定义类能实现"自我描述"的功能,就必须重写Object 类的to String()方法。例如下面程序。
class Apple { private String color; private double weight; public Apple(){ } //提供有参数的构造器 public Apple(String color , double weight) { this.color = color; this.weight = weight; } // color的setter和getter方法 public void setColor(String color) { this.color = color; } public String getColor() { return this.color; } // weight的setter和getter方法 public void setWeight(double weight) { this.weight = weight; } public double getWeight() { return this.weight; } // 重写toString方法,用于实现Apple对象的"自我描述" public String toString() { return "一个苹果,颜色是:" + color + ",重量是:" + weight; } // public String toString() // { // return "Apple[color=" + color + ",weight=" + weight + "]"; // } } public class ToStringTest { public static void main(String[] args) { Apple a = new Apple("红色" , 5.68); // 打印Apple对象 System.out.println(a); } }
6.2.2 ==和equals 方法
Java 程序中测试两个变量是否相等有两种方式: 一种是利用==运算符,另一种是利用equals()方法。当使用==来判断两个变量是否相等时,如果两个变量是基本类型变量,且都是数值类型(不一定要求数据类型严格相同) ,则只要两个变量的值相等,就将返回true 。
但对于两个引用类型变量,只有它们指向同一个对象时,==判断才会返回true . ==不可用于比较类型上没有父子关系的两个对象。下面程序示范了使用二来判断两种类型变量是否相等的结果。
public class EqualTest { public static void main(String[] args) { int it = 65; float fl = 65.0f; // 将输出true System.out.println("65和65.0f是否相等?" + (it == fl)); char ch = 'A'; // 将输出true System.out.println("65和'A'是否相等?" + (it == ch)); String str1 = new String("hello"); String str2 = new String("hello"); // 将输出false System.out.println("str1和str2是否相等?" + (str1 == str2)); // 将输出true System.out.println("str1是否equals str2?" + (str1.equals(str2))); // 由于java.lang.String与EqualTest类没有继承关系, // 所以下面语句导致编译错误 // System.out.println("hello" == new EqualTest()); } }
equals()方法是Object 类提供的一个实例方法,因此所有引用变量都可调用该方法来判断是否与其他引用变量相等。但使用这个方法判断两个对象相等的标准与使用二运算符没有区别,同样要求两个引用变量指向同一个对象才会返回true 。因此这个Object 类提供的equals()方法没有太大的实际意义,如果希望采用自定义的相等标准,则可采用重写equals 方法来实现。
class Person { private String name; private String idStr; public Person(){} public Person(String name , String idStr) { this.name = name; this.idStr = idStr; } // 此处省略name和idStr的setter和getter方法。 // name的setter和getter方法 public void setName(String name) { this.name = name; } public String getName() { return this.name; } // idStr的setter和getter方法 public void setIdStr(String idStr) { this.idStr = idStr; } public String getIdStr() { return this.idStr; } // 重写equals()方法,提供自定义的相等标准 public boolean equals(Object obj) { // 如果两个对象为同一个对象 if (this == obj) return true; // 只有当obj是Person对象 if (obj != null && obj.getClass() == Person.class) { Person personObj = (Person)obj; // 并且当前对象的idStr与obj对象的idStr相等才可判断两个对象相等 if (this.getIdStr().equals(personObj.getIdStr())) { return true; } } return false; } } public class OverrideEqualsRight { public static void main(String[] args) { Person p1 = new Person("孙悟空" , "12343433433"); Person p2 = new Person("孙行者" , "12343433433"); Person p3 = new Person("孙悟饭" , "99933433"); // p1和p2的idStr相等,所以输出true System.out.println("p1和p2是否相等?" + p1.equals(p2)); // p2和p3的idStr不相等,所以输出false System.out.println("p2和p3是否相等?" + p2.equals(p3)); } }
通常而言,正确地重写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)多少次,返回的结果应该保持一致,要么一直是true ,要么一直是false 。
- 对任何不是null 的x , xequals(null)一定返回false 。
Object 默认提供的equals()只是比较对象的地址,即Object 类的equals()方法比较的结果与==运算符比较的结果完全相同。因此,在实际应用中常常需要重写equals()方法,重写equals 方法时,相等条件是由业务要求决定的,因此equals()方法的实现也是由业务要求决定的。
6.3 类成员
static 关键宇修饰的成员就是类成员,前面己经介绍的类成员有类变量、类方法、静态初始化块三个成分, static 关键字不能修饰构造器。static 修饰的类成员属于整个类,不属于单个实例。
6.3.1 理解类成员
在Java 类里只能包含成员变量、方法、构造器、初始化块、内部类( 包括接口、枚举) 5 种成员,目前己经介绍了前面4 种,其中static 可以修饰成员变量、方法、初始化块、内部类( 包括接口、枚举),以static修饰的成员就是类成员。类成员属于整个类,而不属于单个对象。
类变量属于整个类,当系统第一次准备使用该类时,系统会为该类变量分配内存空间,类变量开始生效,直到该类被卸载,该类的类变量所占有的内存才被系统的垃圾回收机制回收。类变量生存范围几乎等同于该类的生存范围。当类初始化完成后,类变量也被初始化完成。
类变量既可通过类来访问, 也可通过类的对象来访问。但通过类的对象来访问类变量时,实际上并不是访问该对象所拥有的变量,因为当系统创建该类的对象时,系统不会再为类变量分配内存,也不会再次对类变量进行初始化,也就是说,对象根本不拥有对应类的类变量。通过对象访问类变量只是一种假象,通过对象访问的依然是该类的类变量,可以这样理解: 当通过对象来访问类变量时,系统会在底层转换为通过该类来访问类变量。
由于对象实际上并不持有类变量,类变量是由该类持有的,同一个类的所有对象访问类变量时,实际上访问的都是该类所持有的变量。因此,从程序运行表面来看,即可看到同一类的所有实例的类变量共享同一块内存区。
类方法也是类成员的一种,类方法也是属于类的,通常直接使用类作为调用者来调用类方法,但也可以使用对象来调用类方法。与类变量类似,即使使用对象来调用类方法,其效果也与采用类来调用类方法完全一样。
当使用实例来访问类成员时,实际上依然是委托给该类来访问类成员,因此即使某个实例为null它也可以访问它所属类的类成员。例如如下代码:
public class NullAccessStatic { private static void test() { System.out.println("static修饰的类方法"); } public static void main(String[] args) { // 定义一个NullAccessStatic变量,其值为null NullAccessStatic nas = null; // 使用null对象调用所属类的静态方法 nas.test(); } }
编译、运行上面程序, 一切正常,程序将打印出" static 修饰的类方法"宇符串,这表明null 对象可以访问它所属类的类成员。
静态初始化块也是类成员的一种,静态初始化块用于执行类初始化动作,在类的初始化阶段,系统会调用该类的静态初始化块来对类进行初始化。一旦该类初始化结束后,静态初始化块将永远不会获得执行的机会。
对static 关键宇而言,有一条非常重要的规则:类成员(包括方法、初始化块、内部类和枚举类)不能访问实例成员(包括成员变量、方法、初始化块、内部类和枚举类) 。因为类成员是属于类的,类成员的作用域比实例成员的作用域更大,完全可能出现类成员己经初始化完成,但实例成员还不曾初始化的情况,如果允许类成员访问实例成员将会引起大量错。
6.3.2 单例(Singleton) 类
大部分时候都把类的构造器定义成public 访问权限,允许任何类自由创建该类的对象。但在某些时候,允许其他类自由创建该类的对象没有任何意义,还可能造成系统性能下降(因为频繁地创建对象、回收对象带来的系统开销问题)。例如,系统可能只有一个窗口管理器、一个假脱机打印设备或一个数据库引擎访问点,此时如果在系统中为这些类创建多个对象就没有太大的实际意义。
如果一个类始终只能创建一个实例,则这个类被称为单例类。
总之,在一些特殊场景下,要求不允许自由创建该类的对象,而只允许为该类创建一个对象。为了避免其他类自由创建该类的实例,应该把该类的构造器使用private 修饰,从而把该类的所有构造器隐藏起来。
根据良好封装的原则: 一旦把该类的构造器隐藏起来,就需要提供一个public 方法作为该类的访问点,用于创建该类的对象,且该方法必须使用static 修饰(因为调用该方法之前还不存在对象,因此调用该方法的不可能是对象,只能是类) 。
除此之外,该类还必须缓存己经创建的对象, 否则该类无法知道是否曾经创建过对象,也就无法保证只创建一个对象。为此该类需要使用一个成员变量来保存曾经创建的对象,因为该成员变量需要被上面的静态方法访问,故该成员变量必须使用static 修饰。
基于上面的介绍,下面程序创建了一个单例类。
class Singleton { // 使用一个类变量来缓存曾经创建的实例 private static Singleton instance; // 将构造器使用private修饰,隐藏该构造器 private Singleton(){} // 提供一个静态方法,用于返回Singleton实例 // 该方法可以加入自定义的控制,保证只产生一个Singleton对象 public static Singleton getInstance() { // 如果instance为null,表明还不曾创建Singleton对象 // 如果instance不为null,则表明已经创建了Singleton对象, // 将不会重新创建新的实例 if (instance == null) { // 创建一个Singleton对象,并将其缓存起来 instance = new Singleton(); } return instance; } } public class SingletonTest { public static void main(String[] args) { // 创建Singleton对象不能通过构造器, // 只能通过getInstance方法来得到实例 Singleton s1 = Singleton.getInstance(); Singleton s2 = Singleton.getInstance(); System.out.println(s1 == s2); // 将输出true } }
6.4 final 修饰符
final 关键字可用于修饰类、变量和方法, final 关键字有点类似C#里的sealed 关键字,用于表示它修饰的类、方法和变量不可改变。
final 修饰变量时,表示该变量一旦获得了初始值就不可被改变, fmal 既可以修饰成员变量( 包括类变量和实例变量), 也可以修饰局部变量、形参。有的书上介绍说final 修饰的变量不能被赋值,这种说法是错误的!严格的说法是, final 修饰的变量不可被改变, 一旦获得了初始值, 该final 变量的值就不能被重新赋值。
由于final 变量获得初始值之后不能被重新赋值, 因此final 修饰成员变量和修饰局部变量时有一定的不同。
6.4 .1 final 成员变量
成员变量是随类初始化或对象初始化而初始化的。当类初始化时,系统会为该类的类变量分配内存,并分配默认值; 当创建对象时,系统会为该对象的实例变量分配内存,并分配默认值。也就是说,当执行静态初始化块时可以对类变量赋初始值:当执行普通初始化块、构造器时可对实例变量赋初始值。因此,成员变量的初始值可以在定义该变量时指定默认值,也可以在初始化块、构造器中指定初始值。
对于final 修饰的成员变量而言,一旦有了初始值,就不能被重新赋值,如果既没有在定义成员变量时指定初始值,也没有在初始化块、构造器中为成员变量指定初始值,那么这些成员变量的值将一直是系统默认分配的0 、'\u0000' 、false 或null , 这些成员变量也就完全失去了存在的意义。因此Java 语法规定: final 修饰的成员变量必须由程序员显式地指定初始值。
归纳起来, final 修饰的类变量、实例变量能指定初始值的地方如下。
- 类变量: 必须在静态初始化块中指定初始值或声明该类变量时指定初始值,而且只能在两个地方的其中之一指定。
- 实例变量:必须在非静态初始化块、声明该实例变量或构造器中指定初始值, 而且只能在三个地方的其中之一指定。
final 修饰的实例变量,要么在定义该实例变量时指定初始值,要么在普通初始化块或构造器中为该实例变量指定初始值。但需要注意的是, 如果普通初始化块己经为某个实例变量指定了初始值,则不能再在构造器中为该实例变量指定初始值; final 修饰的类变量,要么在定义该类变量时指定初始值,要么在静态初始化块中为该类变量指定初始值。
实例变量不能在静态初始化块中指定初始值,因为静态初始化块是静态成员,不可访问实例变量非静态成员:类变量不能在普通初始化块中指定初始值,因为类变量在类初始化阶段己经被初始化了,普通初始化块不能对其重新赋值。
下面程序演示了final 修饰成员变量的效果,详细示范了final 修饰成员变量的各种具体情况。
public class FinalVariableTest { // 定义成员变量时指定默认值,合法。 final int a = 6; // 下面变量将在构造器或初始化块中分配初始值 final String str; final int c; final static double d; // 既没有指定默认值,又没有在初始化块、构造器中指定初始值, // 下面定义的ch实例变量是不合法的。 // final char ch; // 初始化块,可对没有指定默认值的实例变量指定初始值 { //在初始化块中为实例变量指定初始值,合法 str = "Hello"; // 定义a实例变量时已经指定了默认值, // 不能为a重新赋值,因此下面赋值语句非法 // a = 9; } // 静态初始化块,可对没有指定默认值的类变量指定初始值 static { // 在静态初始化块中为类变量指定初始值,合法 d = 5.6; } // 构造器,可对既没有指定默认值、有没有在初始化块中 // 指定初始值的实例变量指定初始值 public FinalVariableTest() { // 如果在初始化块中已经对str指定了初始化值, // 构造器中不能对final变量重新赋值,下面赋值语句非法 // str = "java"; c = 5; } public void changeFinal() { // 普通方法不能为final修饰的成员变量赋值 // d = 1.2; // 不能在普通方法中为final成员变量指定初始值 // ch = 'a'; } public static void main(String[] args) { FinalVariableTest ft = new FinalVariableTest(); System.out.println(ft.a); System.out.println(ft.c); System.out.println(ft.d); } }
如果打算在构造器、初始化块中对final 成员变量进行初始化,则不要在初始化之前直接访问final成员变量;但Java 又允许通过方法来访问final 成员变量,此时会看到系统将final 成员变量默认初始化为0 ('\u0000' 、false 或null) 。例如如下示例程序。
public class FinalErrorTest { // 定义一个final修饰的实例变量 // 系统不会对final成员变量进行默认初始化 final int age; { // age没有初始化,所以此处代码将引起错误。 // System.out.println(age); printAge(); age = 6; System.out.println(age); } public void printAge(){ System.out.println(age); } public static void main(String[] args) { new FinalErrorTest(); } }
上面程序中定义了一个final 成员变量: age, Java 不允许直接访问final 修饰的age 成员变量,所以初始化块中和第一行粗体字代码将引起错误:但第二行粗体字代码通过方法来访问final 修饰的age 成员变量,此时又是允许的,并看到输出0 。只要把定义age 时的final 修饰符去掉,上面程序就正确了。
6.4.2 final 局部变量
系统不会对局部变量进行初始化,局部变量必须由程序员显式初始化。因此使用final 修饰局部变量时, 既可以在定义时指定默认值,也可以不指定默认值。
如果final 修饰的局部变量在定义时没有指定默认值,则可以在后面代码中对该final 变量赋初始值,但只能一次,不能重复赋值; 如果final 修饰的局部变量在定义时己经指定默认值,则后面代码中不能再对该变量赋值。下面程序示范了final 修饰局部变量、形参的情形。
public class FinalLocalVariableTest { public void test(final int a) { // 不能对final修饰的形参赋值,下面语句非法 // a = 5; } public static void main(String[] args) { // 定义final局部变量时指定默认值,则str变量无法重新赋值 final String str = "hello"; // 下面赋值语句非法 // str = "Java"; // 定义final局部变量时没有指定默认值,则d变量可被赋值一次 final double d; // 第一次赋初始值,成功 d = 5.6; // 对final变量重复赋值,下面语句非法 // d = 3.4; } }
在上面程序中还示范了final 修饰形参的情形。因为形参在调用该方法时,由系统根据传入的参数来完成初始化,因此使用final 修饰的形参不能被赋值。
6.4.3 final 修饰基本类型变量和引用类型变量的区别
当使用final 修饰基本类型变量时,不能对基本类型变量重新赋值,因此基本类型变量不能被改变。但对于引用类型变量而言,它保存的仅仅是一个引用, final 只保证这个引用类型变量所引用的地址不会改变, 即一直引用同一个对象,但这个对象完全可以发生改变。
下面程序示范了final 修饰数组和Person 对象的情形。
class Person { private int age; public Person(){} // 有参数的构造器 public Person(int age) { this.age = age; } // 省略age的setter和getter方法 // age的setter和getter方法 public void setAge(int age) { this.age = age; } public int getAge() { return this.age; } } public class FinalReferenceTest { public static void main(String[] args) { // final修饰数组变量,iArr是一个引用变量 final int[] iArr = {5, 6, 12, 9}; System.out.println(Arrays.toString(iArr)); // 对数组元素进行排序,合法 Arrays.sort(iArr); System.out.println(Arrays.toString(iArr)); // 对数组元素赋值,合法 iArr[2] = -8; System.out.println(Arrays.toString(iArr)); // 下面语句对iArr重新赋值,非法 // iArr = null; // final修饰Person变量,p是一个引用变量 final Person p = new Person(45); // 改变Person对象的age实例变量,合法 p.setAge(23); System.out.println(p.getAge()); // 下面语句对p重新赋值,非法 // p = null; } }
从上面程序中可以看出,使用final 修饰的引用类型变量不能被重新赋值,但可以改变引用类型变量所引用对象的内容。例如上面凶rr 变量所引用的数组对象, final 修饰后的iArr变量不能被重新赋值,但iArr所引用数组的数组元素可以被改变。与此类似的是, p 变量也使用了final 修饰,表明p变量不能被重新赋值, 但p变量所引用Person 对象的成员变量的值可以被改变。
6.4.4 可执行"宏替换"的final 变量
对一个final 变量来说,不管它是类变量、实例变量,还是局部变量,只要该变量满足三个条件,这个final 变量就不再是一个变量,而是相当于一个直接量。
- 使用final 修饰符修饰。
- 在定义该final 变量时指定了初始值。
- 该初始值可以在编译时就被确定下来。
看如下程序。
public class FinalLocalTest { public static void main(String[] args) { // 定义一个普通局部变量 final int a = 5; System.out.println(a); } }
上面程序中的粗体字代码定义了一个final 局部变量,并在定义该final 变量时指定初始值为5 。对于这个程序来说,变量a 其实根本不存在,当程序执行System.out.println( a);代码时,实际转换为执行System.out.println(5) 。
除上面那种为final 变量赋值时赋直接量的情况外,如果被赋的表达式只是基本的算术表达式或字符串连接运算,没有访问普通变量,调用方法, Java 编译器同样会将这种final 变量当成"宏变量"处理。示例如下。
public class FinalReplaceTest { public static void main(String[] args) { // 下面定义了4个final“宏变量” final int a = 5 + 2; final double b = 1.2 / 3; final String str = "疯狂" + "Java"; final String book = "疯狂Java讲义:" + 99.0; // 下面的book2变量的值因为调用了方法,所以无法在编译时被确定下来 final String book2 = "疯狂Java讲义:" + String.valueOf(99.0); System.out.println(book == "疯狂Java讲义:99.0"); System.out.println(book2 == "疯狂Java讲义:99.0"); } }
6.4.5 final 方法
final 修饰的方法不可被重写,如果出于某些原因,不希望子类重写父类的某个方法,则可以使用final 修饰该方法。
Java 提供的Object 类里就有一个final 方法: getClass(),因为Java 不希望任何类重写这个方法,所以使用final 把这个方法密封起来。但对于该类提供的toString()和equals()方法,都允许子类重写, 因此没有使用final 修饰它们。
下面程序试图重写final 方法,将会引发编译错误。
public class FinalMethodTest { public final void test(){} } class Sub extends FinalMethodTest { // 下面方法定义将出现编译错误,不能重写final方法 public void test(){} }
上面程序中父类是FinalMethodTest , 该类里定义的test()方法是一个final 方法,如果其子类试图重写该方法,将会引发编译错误。
对于一个private 方法,因为它仅在当前类中可见,其子类无法访问该方法,所以子类无法重写该方法一一如果子类中定义一个与父类private 方法有相同方法名、相同形参列表、相同返回值类型的方法,也不是方法重写,只是重新定义了一个新方法。因此,即使使用final 修饰一个private 访问权限的方法,依然可以在其子类中定义与该方法具有相同方法名、相同形参列表、相同返回值类型的方法。
下面程序示范了如何在子类中"重写"父类的private final 方法。
public class PrivateFinalMethodTest { private final void test(){} } class Sub extends PrivateFinalMethodTest { // 下面方法定义将不会出现问题 public void test(){} }
上面程序没有任何问题,虽然子类和父类同样包含了同名的void test()方法,但子类并不是重写父类的方法,因此即使父类的void test()方法使用了final 修饰,子类中依然可以定义void test()方法。
final修饰的方法仅仅是不能被重写,并不是不能被重载,因此下面程序完全没有问题。
public class FinalOverload { //final 修饰的方式是不能重写,但是可以重载 public final void test(); public final void test(String name); }
6.4.6 final 类
final 修饰的类不可以有子类, 例如java.lang.Math 类就是一个final 类,它不可以有子类。
当子类继承父类时,将可以访问到父类内部数据,并可通过重写父类方法来改变父类方法的实现细节,这可能导致一些不安全的因素。为了保证某个类不可被继承,则可以使用final 修饰这个类。下面代码示范了final 修饰的类不可被继承。
public final class FinalClass {} //下面的代码无法编译通过 public sub extends FinalClass { }
6.4.7 不可变类
不可变( immutable ) 类的意思是创建该类的实例后, 该实例的实例变量是不可改变的。Java 提供的8 个包装类和java.lang.String 类都是不可变类, 当创建它们的实例后, 其实例的实例变量不可改变。
例如如下代码:
Double d=new Double(6.5); String str=new String("Hello");
上面程序创建了一个Double 对象和一个String 对象,并为这个两对象传入了6.5 和"Hello"字符串作为参数,那么Double 类和String 类肯定需要提供实例变量来保存这两个参数,但程序无法修改这两个实例变量的值,因此Double 类和String 类没有提供修改它们的方法。
如果需要创建自定义的不可变类,可遵守如下规则。
- 使用private 和final 修饰符来修饰该类的成员变量。
- 提供带参数构造器,用于根据传入参数来初始化类里的成员变量。
- 仅为该类的成员变量提供getter 方法,不要为该类的成员变量提供setter 方法,因为普通方法无法修改final 修饰的成员变量。
- 如果有必要,重写Object 类的hashCode()和equals()方法。 equals()方法根据关键成员变量来作为两个对象是否相等的标准,除此之外,还应该保证两个用equals()方法判断为相等的对象的hashCode()也相等。
例如, java.lang.String 这个类就做得很好, 它就是根据String 对象里的字符序列来作为相等的标准,其hashCode()方法也是根据字符序列计算得到的。下面程序测试了java .lang.String 类的equals()和hashCode()方法。
public class ImmutableStringTest { public static void main(String[] args) { String str1 = new String("Hello"); String str2 = new String("Hello"); System.out.println(str1 == str2); // 输出false System.out.println(str1.equals(str2)); // 输出true // 下面两次输出的hashCode相同 System.out.println(str1.hashCode()); System.out.println(str2.hashCode()); } }
下面定义一个不可变的Address 类,程序把Address 类的detail 和postCode 成员变量都使用private隐藏起来,并使用final 修饰这两个成员变量, 不允许其他方法修改这两个成员变量的值。
public class Address { private final String detail; private final String postCode; // 在构造器里初始化两个实例变量 public Address() { this.detail = ""; this.postCode = ""; } public Address(String detail , String postCode) { this.detail = detail; this.postCode = postCode; } // 仅为两个实例变量提供getter方法 public String getDetail() { return this.detail; } public String getPostCode() { return this.postCode; } //重写equals()方法,判断两个对象是否相等。 public boolean equals(Object obj) { if (this == obj) { return true; } if(obj != null && obj.getClass() == Address.class) { Address ad = (Address)obj; // 当detail和postCode相等时,可认为两个Address对象相等。 if (this.getDetail().equals(ad.getDetail()) && this.getPostCode().equals(ad.getPostCode())) { return true; } } return false; } public int hashCode() { return detail.hashCode() + postCode.hashCode() * 31; } }
对于上面的Address 类, 当程序创建了Address 对象后,同样无法修改该Address 对象的detail 和postCode 实例变量。
与不可变类对应的是可变类,可变类的含义是该类的实例变量是可变的。大部分时候所创建的类都是可变类, 特别是JavaBean ,因为总是为其实例变量提供了setter 和getter 方法。
与可变类相比, 不可变类的实例在整个生命周期中永远处于初始化状态, 它的实例变量不可改变。因此对不可变类的实例的控制将更加简单。
前面介绍final 关键字时提到, 当使用final 修饰引用类型变量时,仅表示这个引用类型变量不可被重新赋值,但引用类型变量所指向的对象依然可改变。这就产生了一个问题: 当创建不可变类时,如果它包含成员变量的类型是可变的, 那么其对象的成员变量的值依然是可改变的一一这个不可变类其实是
失败的。
下面程序试图定义一个不可变的Person 类, 但因为Person 类包含一个引用类型的成员变量, 且这个引用类是可变类, 所以导致Person 类也变成了可变类。
class Name { private String firstName; private String lastName; public Name(){} public Name(String firstName , String lastName) { this.firstName = firstName; this.lastName = lastName; } // 省略firstName、lastName的setter和getter方法 public void setFirstName(String firstName) { this.firstName = firstName; } public String getFirstName() { return this.firstName; } public void setLastName(String lastName) { this.lastName = lastName; } public String getLastName() { return this.lastName; } } public class Person { private final Name name; public Person(Name name) { this.name = name; } public Name getName() { return name; } public static void main(String[] args) { Name n = new Name("悟空", "孙"); Person p = new Person(n); // Person对象的name的firstName值为"悟空" System.out.println(p.getName().getFirstName()); // 改变Person对象name的firstName值 n.setFirstName("八戒"); // Person对象的name的firstName值被改为"八戒" System.out.println(p.getName().getFirstName()); } }
6.4.8 缓存实例的不可变类
不可变类的实例状态不可改变,可以很方便地被多个对象所共享。如果程序经常需要使用相同的不可变类实例,则应该考虑缓存这种不可变类的实例。毕竟重复创建相同的对象没有太大的意义,而且加大系统开销。如果可能,应该将已经创建的不可变类的实例进行缓存。
缓存是软件设计中一个非常有用的模式,缓存的实现方式有很多种,不同的实现方式可能存在较大的性能差别,关于缓存的性能问题此处不做深入讨论。
本节将使用一个数组来作为缓存池,从而实现一个缓存实例的不可变类。
class CacheImmutale { private static int MAX_SIZE = 10; // 使用数组来缓存已有的实例 private static CacheImmutale[] cache = new CacheImmutale[MAX_SIZE]; // 记录缓存实例在缓存中的位置,cache[pos-1]是最新缓存的实例 private static int pos = 0; private final String name; private CacheImmutale(String name) { this.name = name; } public String getName() { return name; } public static CacheImmutale valueOf(String name) { // 遍历已缓存的对象, for (int i = 0 ; i < MAX_SIZE; i++) { // 如果已有相同实例,直接返回该缓存的实例 if (cache[i] != null && cache[i].getName().equals(name)) { return cache[i]; } } // 如果缓存池已满 if (pos == MAX_SIZE) { // 把缓存的第一个对象覆盖,即把刚刚生成的对象放在缓存池的最开始位置。 cache[0] = new CacheImmutale(name); // 把pos设为1 pos = 1; } else { // 把新创建的对象缓存起来,pos加1 cache[pos++] = new CacheImmutale(name); } return cache[pos - 1]; } public boolean equals(Object obj) { if(this == obj) { return true; } if (obj != null && obj.getClass() == CacheImmutale.class) { CacheImmutale ci = (CacheImmutale)obj; return name.equals(ci.getName()); } return false; } public int hashCode() { return name.hashCode(); } } public class CacheImmutaleTest { public static void main(String[] args) { CacheImmutale c1 = CacheImmutale.valueOf("hello"); CacheImmutale c2 = CacheImmutale.valueOf("hello"); // 下面代码将输出true System.out.println(c1 == c2); } }
6.5 抽象类
当编写一个类时, 常常会为该类定义一些方法, 这些方法用以描述该类的行为方式, 那么这些方法都有具体的方法体。但在某些情况下, 某个父类只是知道其子类应该包含怎样的方法,但无法准确地知道这些子类如何实现这些方法。例如定义了一个Shape 类, 这个类应该提供一个计算周长的方法calPerimeter() , 但不同Shape 子类对周长的计算方法是不一样的,即Shape 类无法准确地知道其子类计算周长的方法。
可能有读者会提出, 既然Shape 类不知道如何实现calPerimeter()方法,那就干脆不要管它了! 这不是一个好思路: 假设有一个Shape 引用变量, 该变量实际上引用到Shape 子类的实例, 那么这个Shape变量就无法调用calPerimeter()方法, 必须将其强制类型转换为其子类类型, 才可调用calPerimeter()方法,这就降低了程序的灵活性。
如何既能让Shape 类里包含calPerimeter()方法, 又无须提供其方法实现呢?使用抽象方法即可满足该要求: 抽象方法是只有方法签名,没有方法实现的方法。
6.5.1 抽象方法和抽象类
抽象方法和抽象类必须使用abstract 修饰符来定义, 有抽象方法的类只能被定义成抽象类,抽象类里可以没有抽象方法。
抽象方法和抽象类的规则如下。
- 抽象类必须使用abstract 修饰符来修饰,抽象方法也必须使用ab stract 修饰符来修饰,抽象方法不能有方法体。
- 抽象类不能被实例化, 无法使用new 关键字来调用抽象类的构造器创建抽象类的实例。即使抽象类里不包含抽象方法,这个抽象类也不能创建实例。
- 抽象类可以包含成员变量、方法(普通方法和抽象方法都可以)、构造器、初始化块、内部类(接口、枚举) 5 种成分。抽象类的构造器不能用于创建实例, 主要是用于被其子类调用。
- 含有抽象方法的类( 包括直接定义了一个抽象方法: 或继承了一个抽象父类,但没有完全实现父类包含的抽象方法; 或实现了一个接口, 但没有完全实现接口包含的抽象方法三种情况)只能被定义成抽象类。
定义抽象方法只需在普通方法上增加abstract 修饰符, 并把普通方法的方法体( 也就是方法后花括号括起来的部分〉全部去掉, 并在方法后增加分号即可。
定义抽象类只需在普通类上增加abstract 修饰符即可。甚至一个普通类(没有包含抽象方法的类)增加abstract 修饰符后也将变成抽象类。
下面定义一个Shape 抽象类。
public abstract class Shape { { System.out.println("执行Shape的初始化块..."); } private String color; // 定义一个计算周长的抽象方法 public abstract double calPerimeter(); // 定义一个返回形状的抽象方法 public abstract String getType(); // 定义Shape的构造器,该构造器并不是用于创建Shape对象, // 而是用于被子类调用 public Shape(){} public Shape(String color) { System.out.println("执行Shape的构造器..."); this.color = color; } // 省略color的setter和getter方法 public void setColor(String color) { this.color = color; } public String getColor() { return this.color; } }
上面的Shape 类里包含了两个抽象方法: calPerimeter()和getType() ,所以这个Shape 类只能被定义成抽象类。Shape 类里既包含了初始化块,也包含了构造器,这些都不是在创建Shape 对象时被调用的,而是在创建其子类的实例时被调用。
抽象类不能用于创建实例,只能当作父类被其他子类继承。
下面定义一个三角形类, 三角形类被定义成普通类,因此必须实现Shape 类里的所有抽象方法。
public class Triangle extends Shape { // 定义三角形的三边 private double a; private double b; private double c; public Triangle(String color , double a , double b , double c) { super(color); this.setSides(a , b , c); } public void setSides(double a , double b , double c) { if (a >= b + c || b >= a + c || c >= a + b) { System.out.println("三角形两边之和必须大于第三边"); return; } this.a = a; this.b = b; this.c = c; } // 重写Shape类的的计算周长的抽象方法 public double calPerimeter() { return a + b + c; } // 重写Shape类的的返回形状的抽象方法 public String getType() { return "三角形"; } }
上面的Triangle 类继承了Shape 抽象类,并实现了Shape 类中两个抽象方法,是一个普通类,因此可以创建Triangle 类的实例,可以让一个Shape 类型的引用变量指向Triangle 对象。
下面再定义一个Circle 普通类, Circle 类也是Shape 类的一个子类。
public class Circle extends Shape { private double radius; public Circle(String color , double radius) { super(color); this.radius = radius; } public void setRadius(double radius) { this.radius = radius; } // 重写Shape类的的计算周长的抽象方法 public double calPerimeter() { return 2 * Math.PI * radius; } // 重写Shape类的的返回形状的抽象方法 public String getType() { return getColor() + "圆形"; } public static void main(String[] args) { Shape s1 = new Triangle("黑色" , 3 , 4, 5); Shape s2 = new Circle("黄色" , 3); System.out.println(s1.getType()); System.out.println(s1.calPerimeter()); System.out.println(s2.getType()); System.out.println(s2.calPerimeter()); } }
利用抽象类和抽象方法的优势,可以更好地发挥多态的优势,使得程序更加灵活。
当使用abstract 修饰类时,表明这个类只能被继承;当使用abstract 修饰方法时, 表明这个方法必须由子类提供实现(即重写) 。而final 修饰的类不能被继承,final修饰的方法不能被重写。因此final和abstract 永远不能同时使用。
除此之外,当使用stahc 修饰一个方法时,表明这个方法属于该类本身,即通过类就可调用该方法,但如果该方法被定义成抽象方法,则将导致通过该类来调用该方法时出现错误(调用了一个没有方法体的方法肯定会引起错误)。因此static 和abstract 不能同时修饰某个方法,即没有所谓的类抽象方法。
6.5.2 抽象类的作用
从前面的示例程序可以看出,抽象类不能创建实例,只能当成父类来被继承。从语义的角度来看,抽象类是从多个具体类中抽象出来的父类,它具有更高层次的抽象。从多个具有相同特征的类中抽象出一个抽象类,以这个抽象类作为其子类的模板,从而避免了子类设计的随意性。
抽象类体现的就是一种模板模式的设计,抽象类作为多个子类的通用模板,子类在抽象类的基础上进行扩展、改造,但子类总体上会大致保留抽象类的行为方式。
如果编写一个抽象父类,父类提供了多个子类的通用方法,并把一个或多个方法留给其子类实现,这就是一种模板模式,模板模式也是十分常见且简单的设计模式之一。例如前面介绍的Shape 、Circle和Triangle 三个类,已经使用了模板模式。下面再介绍一个模板模式的范例,在这个范例的抽象父类中,父类的普通方法依赖于一个抽象方法,而抽象方法则推迟到子类中提供实现。
public abstract class SpeedMeter { // 转速 private double turnRate; public SpeedMeter(){} // 把计算车轮周长的方法定义成抽象方法 public abstract double calGirth(); public void setTurnRate(double turnRate) { this.turnRate = turnRate; } // 定义计算速度的通用算法 public double getSpeed() { // 速度等于 周长 * 转速 return calGirth() * turnRate; } }
上面程序定义了一个抽象的SpeedMeter 类(车速表) ,该表里定义了一个getSpeed()方法, 该方法用于返回当前车速, getSpeed()方法依赖于calGirth()方法的返回值。对于一个抽象的SpeedMeter 类而言,它无法确定车轮的周长,因此calGirth()方法必须推迟到其子类中实现。
下面是其子类CarSpeedMeter 的代码,该类实现了其抽象父类的calGirth() 方法,既可创建CarSpeedMeter 类的对象,也可通过该对象来取得当前速度。
public class CarSpeedMeter extends SpeedMeter { private double radius; public CarSpeedMeter(double radius) { this.radius = radius; } public double calGirth(){ return radius * 2 * Math.PI; } public static void main(String[] args) { CarSpeedMeter csm = new CarSpeedMeter(0.34); csm.setTurnRate(15); System.out.println(csm.getSpeed()); } }
SpeedMeter 类里提供了速度表的通用算法,但一些具体的实现细节则推迟到其子类CarSpeedMeter类中实现。这也是一种典型的模板模式。
模板模式在面向对象的软件中很常用,其原理简单,实现也很简单。下面是使用模板模式的一些简单规则。
- 抽象父类可以只定义需要使用的某些方法,把不能实现的部分抽象成抽象方法,留给其子类去实现。
- 父类中可能包含需要调用其他系列方法的方法,这些被调方法既可以由父类实现,也可以由其子类实现。父类里提供的方法只是定义了一个通用算法,其实现也许并不完全由自身实现,而必须依赖于其子类的辅助。
6.6 Java 9 改进的接口
抽象类是从多个类中抽象出来的模板,如果将这种抽象进行得更彻底,则可以提炼出一种更加特殊的"抽象类"一一接口( interface) . Java 9 对接口进行了改进,允许在接口中定义默认方法和类方法,默认方法和类方法都可以提供方法实现, Java 9 为接口增加了一种私有方法,私有方法也可提供方法实现。
6.6.1 接口的概念
接口定义了一种规范,接口定义了某一批类所需要遵守的规范,接口不关心这些类的内部状态数据, 也不关心这些类里方法的实现细节,它只规定这批类里必须提供某些方法,提供这些方法的类就可满足实际需要。
可见,接口是从多个相似类中抽象出来的规范,接口不提供任何实现。接口体现的是规范和实现分离的设计哲学。
让规范和实现分离正是接口的好处,让软件系统的各组件之间面向接口搞合,是一种松藕合的设计。
接口定义的是多个类共同的公共行为规范,这些行为是与外部交流的通道,这就意味着接口里通常是定义一组公用方法。
6.6.2 Java 9 中接口的定义
和类定义不同,定义接口不再使用class 关键字,而是使用interface 关键字。接口定义的基本语法如下:
[修饰符] interface 接口名 extends 父接口1 ,父接口2. . . { 零个到多个常量定义.. . 零个到多个抽象方法定义.. . 零个到多个内部类、接口、枚举定义.. . 零个到多个私有方法、默认方法或类方法定义.. . }
对上面语法的详细说明如下。
- 修饰符可以是public 或者省略,如果省略了public 访问控制符,则默认采用包权限访问控制符,即只有在相同包结构下才可以访问该接口。
- 接口名应与类名采用相同的命名规则,即如果仅从语法角度来看,接口名只要是合法的标识符即可;如果要遵守Java 可读性规范,则接口名应由多个有意义的单词连缀而成,每个单词首字母大写,单词与单词之间无须任何分隔符。接口名通常能够使用形容词。
- 一个接口可以有多个直接父接口,但接口只能继承接口,不能继承类。
由于接口定义的是一种规范,因此接口里不能包含构造器和初始化块定义。接口里可以包含成员变量(只能是静态常量)、方法(只能是抽象实例方法、类方法、默认方法或私有方法)、内部类(包括内部接口、枚举)定义。
对比接口和类的定义方式,不难发现接口的成员比类里的成员少了两种,而且接口里的成员变量只能是静态常量,接口里的方法只能是抽象方法、类方法、默认方法或私有方法。
接口里定义的是多个类共同的公共行为规范,因此接口里的常量、方法、内部类和内部枚举都是public 访问权限。定义接口成员时,可以省略访问控制修饰符,如果指定访问控制修饰符,则只能使用public 访问控制修饰符。
Java 9 为接口增加了一种新的私有方法,其实私有方法的主要作用就是作为工具方法,为接口中的默认方法或类方法提供支持。私有方法可以拥有方法体,但私有方法不能使用default 修饰。私有方法可以使用staÌlc 修饰,也就是说,私有方法既可是类方法,也可是实例方法。
对于接口里定义的静态常量而言,它们是接口相关的,因此系统会自动为这些成员变量增加static和final 两个修饰符。也就是说,在接口中定义成员变量时,不管是否使用public static final 修饰符,接口里的成员变量总是使用这三个修饰符来修饰。而且接口里没有构造器和初始化块,因此接口里定义的成员变量只能在定义时指定默认值。
接口里定义成员变量采用如下两行代码的结果完全一样。
//系统自动为接口里定义的成员变量增加public static final 修饰符 int MAX_SIZE = 50; public static final int MAX_SIZE= 50 ;
接口里定义的方法只能是抽象方法、类方法、默认方法或私有方法,因此如果不是定义默认方法、类方法或私有方法,系统将自动为普通方法增加abstract修饰符:定义接口里的普通方法时不管是否使用public abstract 修饰符,接口里的普通方法总是使用public abstract 来修饰。接口里的普通方法不能有方法实现(方法体);但类方法、默认方法、私有方法都必须有方法实现(方法体)。
public interface Output { // 接口里定义的成员变量只能是常量 int MAX_CACHE_LINE = 50; // 接口里定义的普通方法只能是public的抽象方法 void out(); void getData(String msg); // 在接口中定义默认方法,需要使用default修饰 default void print(String... msgs) { for (String msg : msgs) { System.out.println(msg); } } // 在接口中定义默认方法,需要使用default修饰 default void test() { System.out.println("默认的test()方法"); } // 在接口中定义类方法,需要使用static修饰 static String staticTest() { return "接口里的类方法"; } // 定义私有方法 private void foo() { System.out.println("foo私有方法"); } // 定义私有静态方法 private static void bar() { System.out.println("bar私有静态方法"); } }
上面定义了一个Output 接口,这个接口里包含了一个成员变量: MAX_CACHE_LINE 。除此之外,这个接口还定义了两个普通方法: 表示取得数据的getData()方法和表示输出的out()方法。这就定义了Output 接口的规范:只要某个类能取得数据,并可以将数据输出,那它就是一个输出设备,至于这个设备的实现细节, 这里暂时不关心。
Java 8 允许在接口中定义默认方法,默认方法必须使用default 修饰,该方法不能使用static 修饰,无论程序是否指定,默认方法总是使用public 修饰一一如果开发者没有指定public, 系统会自动为默认方法添加public 修饰符。由于默认方法并没有static 修饰,因此不能直接使用接口来调用默认方法,需要使用接口的实现类的实例来调用这些默认方法。
Java 8 允许在接口中定义类方法, 类方法必须使用static 修饰, 该方法不能使用default 修饰,无论程序是否指定,类方法总是使用public修饰一一如果开发者没有指定public ,系统会自动为类方法添加public 修饰符。类方法可以直接使用接口来调用。
Java 9 增加了带方法体的私有方法,这也是Java 8 埋下的伏笔: Java 8 允许在接口中定义带方法体的默认方法和类方法一一这样势必会引发一个问题, 当两个默认方法(或类方法) 中包含一段相同的实现逻辑时,程序必然考虑将这段实现逻辑抽取成工具方法,而工具方法是应该被隐藏的,这就是Java 9增加私有方法的必然性。
接口里的成员变量默认是使用public static final 修饰的,因此即使另一个类处于不同包下,也可以通过接口来访问接口里的成员变量。例如下面程序。
public class OutputFieldTest { public static void main(String[] args) { // 访问另一个包中的Output接口的MAX_CACHE_LINE System.out.println(Output.MAX_CACHE_LINE); // 下面语句将引起"为final变量赋值"的编译异常 // lee.Output.MAX_CACHE_LINE = 20; // 使用接口来调用类方法 System.out.println(Output.staticTest()); } }
6.6.3 接口的继承
接口的继承和类继承不一样,接口完全支持多继承,即一个接口可以有多个直接父接口。和类继承相似,子接口扩展某个父接口,将会获得父接口里定义的所有抽象方法、常量。
一个接口继承多个父接口时,多个父接口排在extends 关键宇之后,多个父接口之间以英文逗号(,)隔开。下面程序定义了三个接口, 第三个接口继承了前面两个接口。
interface InterfaceA { int PROP_A = 5; void testA(); } interface InterfaceB { int PROP_B = 6; void testB(); } interface InterfaceC extends InterfaceA, InterfaceB { int PROP_C = 7; void testC(); } public class InterfaceExtendsTest { public static void main(String[] args) { System.out.println(InterfaceC.PROP_A); System.out.println(InterfaceC.PROP_B); System.out.println(InterfaceC.PROP_C); } }
上面程序中的InterfaceC 接口继承了InterfaceA 和InterfaceB ,所以InterfaceC 中获得了它们的常量,因此在main()方法中看到通过InterfaceC 来访问PROP_A 、PROP_B 和PROP_C 常量。
6.6.4 使用接口
接口不能用于创建实例, 但接口可以用于声明引用类型变量。当使用接口来声明引用类型变量时,这个引用类型变量必须引用到其实现类的对象。除此之外,接口的主要用途就是被实现类实现。归纳起来,接口主要有如下用途。
- 定义变量,也可用于进行强制类型转换。
- 调用接口中定义的常量。
- 被其他类实现。
一个类可以实现一个或多个接口,继承使用extends 关键字,实现则使用implements 关键字。因为一个类可以实现多个接口,这也是Java 为单继承灵活性不足所做的补充。类实现接口的语法格式如下:
[修饰符] class 类名 extends 父类 implements 接口1,接口2. . . { 类体部分 }
实现接口与继承父类相似, 一样可以获得所实现接口里定义的常量(成员变量)、方法(包括抽象方法和默认方法)。
让类实现接口需要类定义后增加implements 部分,当需要实现多个接口时,多个接口之间以英文逗号(,)隔开。一个类可以继承一个父类,并同时实现多个接口, implements 部分必须放在extends 部分之后。
一个类实现了一个或多个接口之后,这个类必须完全实现这些接口里所定义的全部抽象方法(也就是重写这些抽象方法);否则,该类将保留从父接口那里继承到的抽象方法,该类也必须定义成抽象类。
一个类实现某个接口时, 该类将会获得接口中定义的常量(成员变量)、方法等, 因此可以把实现接口理解为一种特殊的继承,相当于实现类继承了一个彻底抽象的类(相当于除默认方法外,所有方法都是抽象方法的类)。
下面看一个实现接口的类。
// 定义一个Product接口 interface Product { int getProduceTime(); } // 让Printer类实现Output和Product接口 public class Printer implements Output , Product { private String[] printData = new String[MAX_CACHE_LINE]; // 用以记录当前需打印的作业数 private int dataNum = 0; public void out() { // 只要还有作业,继续打印 while(dataNum > 0) { System.out.println("打印机打印:" + printData[0]); // 把作业队列整体前移一位,并将剩下的作业数减1 System.arraycopy(printData , 1 , printData, 0, --dataNum); } } public void getData(String msg) { if (dataNum >= MAX_CACHE_LINE) { System.out.println("输出队列已满,添加失败"); } else { // 把打印数据添加到队列里,已保存数据的数量加1。 printData[dataNum++] = msg; } } public int getProduceTime() { return 45; } public static void main(String[] args) { // 创建一个Printer对象,当成Output使用 Output o = new Printer(); o.getData("轻量级Java EE企业应用实战"); o.getData("Java"); o.out(); o.getData("Android"); o.getData("Ajax"); o.out(); // 调用Output接口中定义的默认方法 o.print("孙悟空" , "猪八戒" , "白骨精"); o.test(); // 创建一个Printer对象,当成Product使用 Product p = new Printer(); System.out.println(p.getProduceTime()); // 所有接口类型的引用变量都可直接赋给Object类型的变量 Object obj = p; } }
接口不能显式继承任何类, 但所有接口类型的引用变量都可以直接赋给Object 类型的引用变量。所以在上面程序中可以把Product 类型的变量直接赋给Object 类型变量,这是利用向上转型来实现的,因为编译器知道任何Java 对象都必须是Object 或其子类的实例, Product 类型的对象也不例外(它必须是Product 接口实现类的对象,该实现类肯定是Object 的显式或隐式子类)。
6.6.5 接口和抽象类
接口和抽象类很像,它们都具有如下特征。
- 接口和抽象类都不能被实例化,它们都位于继承树的顶端,用于被其他类实现和继承。
- 接口和抽象类都可以包含抽象方法,实现接口或继承抽象类的普通子类都必须实现这些抽象方法。
但接口和抽象类之间的差别非常大,这种差别主要体现在二者设计目的上。下面具体分析二者的差别。
接口作为系统与外界交互的窗口, 接口体现的是一种规范。对于接口的实现者而言,接口规定了实现者必须向外提供哪些服务(以方法的形式来提供);对于接口的调用者而言, 接口规定了调用者可以调用哪些服务,以及如何调用这些服务(就是如何来调用方法) 。当在一个程序中使用接口时,接口是多个模块间的藕合标准:当在多个应用程序之间使用接口时, 接口是多个程序之间的通信标准。
从某种程度上来看,接口类似于整个系统的"总纲",它制定了系统各模块应该遵循的标准,因此一个系统中的接口不应该经常改变。一旦接口被改变, 对整个系统甚至其他系统的影响将是辐射式的,导致系统中大部分类都需要改写。
抽象类则不一样,抽象类作为系统中多个子类的共同父类, 它所体现的是一种模板式设计。抽象类作为多个子类的抽象父类,可以被当成系统实现过程中的中间产品, 这个中间产品己经实现了系统的部分功能(那些己经提供实现的方法) ,但这个产品依然不能当成最终产品,必须有更进一步的完善,这种完善可能有几种不同方式。
除此之外,接口和抽象类在用法上也存在如下差别。
- 接口里只能包含抽象方法、静态方法、默认方法和私有方法,不能为普通方法提供方法实现:抽象类则完全可以包含普通方法。
- 接口里只能定义静态常量,不能定义普通成员变量:抽象类里则既可以定义普通成员变量,也可以定义静态常量。
- 接口里不包含构造器;抽象类里可以包含构造器,抽象类里的构造器并不是用于创建对象,而是让其子类调用这些构造器来完成属于抽象类的初始化操作。
- 接口里不能包含初始化块:但抽象类则完全可以包含初始化块。
- 一个类最多只能有一个直接父类,包括抽象类;但一个类可以直接实现多个接口,通过实现多个接口可以弥补Java 单继承的不足。
6.6.6 面向接口编程
前面已经提到, 接口体现的是一种规范和实现分离的设计哲学, 充分利用接口可以极好地降低程序各模块之间的辑合,从而提高系统的可扩展性和可维护性。
基于这种原则,很多软件架构设计理论都倡导"面向接口"编程,而不是面向实现类编程, 希望通过面向接口编程来降低程序的糯合。下面介绍两种常用场景来示范面向接口编程的优势。
1. 简单工厂模式
有一个场景: 假设程序中有个Computer 类需要组合一个输出设备,现在有两个选择: 直接让Computer 类组合一个Printer , 或者让Computer 类组合一个Output ,那么到底采用哪种方式更好呢?
假设让Computer 类组合一个Printer 对象,如果有一天系统需要重构,需要使用BetterPrinter 来代替Printer , 这就需要打开Computer 类源代码进行修改。如果系统中只有一个Computer 类组合了Printer还好,但如果系统中有100 个类组合了Printer ,甚至1000 个、10000 个…… 将意味着需要打开100 个、1000 个、10000 个类进行修改,这是多么大的工作量啊!
为了避免这个问题,工厂模式建议让Computer 类组合一个Output 类型的对象,将Computer 类与Printer 类完全分离。Computer 对象实际组合的是Printer 对象还是BetlerPrinter 对象, 对Computer 而言完全透明。当Printer 对象切换到BetterPrinter 对象时,系统完全不受影响。下面是这个Computer 类的定义代码。
public class Computer { private Output out; public Computer(Output out) { this.out = out; } // 定义一个模拟获取字符串输入的方法 public void keyIn(String msg) { out.getData(msg); } // 定义一个模拟打印的方法 public void print() { out.out(); } }
上面的Computer 类己经完全与Printer 类分离,只是与Output 接口稠合。Computer 不再负责创建Output 对象,系统提供一个Output 工厂来负责生成Output 对象。这个OutputFactory 工厂类代码如下。
public class OutputFactory { public Output getOutput() { // return new Printer(); return new BetterPrinter(); } public static void main(String[] args) { OutputFactory of = new OutputFactory(); Computer c = new Computer(of.getOutput()); c.keyIn("轻量级Java EE企业应用实战"); c.keyIn("Java讲义"); c.print(); } }
在该OutputFactory类中包含了一个getOutput()方法, 该方法返回一个Output 实现类的实例,该方法负责创建Output 实例,具体创建哪一个实现类的对象由该方法决定(具体由该方法中的粗体部分控制,当然也可以增加更复杂的控制逻辑) 。如果系统需要将Printer 改为BetterPrinter 实现类,只需让BetterPrinter 实现Output 接口,并改变OutputFactory 类中的getOutput()方法即可。
下面是BetterPrinter 实现类的代码, BetterPrinter 只是对原有的Printer 进行简单修改,以模拟系统重构后的改进。
public class BetterPrinter implements Output { private String[] printData = new String[MAX_CACHE_LINE * 2]; // 用以记录当前需打印的作业数 private int dataNum = 0; public void out() { // 只要还有作业,继续打印 while(dataNum > 0) { System.out.println("高速打印机正在打印:" + printData[0]); // 把作业队列整体前移一位,并将剩下的作业数减1 System.arraycopy(printData , 1, printData, 0, --dataNum); } } public void getData(String msg) { if (dataNum >= MAX_CACHE_LINE * 2) { System.out.println("输出队列已满,添加失败"); } else { // 把打印数据添加到队列里,已保存数据的数量加1。 printData[dataNum++] = msg; } } }
上面的BetterPrinter 类也实现了Output 接口,因此也可当成Output 对象使用,于是只要把OutputFactory 工厂类的getOutput()方法中粗体部分改为如下代码:
return new BetterPrinter () ;
再次运行前面的OutputFactory.java程序,发现系统运行时已经改为BetterPrinter 对象, 而不再是原来的Printer 对象。
通过这种方式,即可把所有生成Output 对象的逻辑集中在OutputFactory工厂类中管理,而所有需要使用Output 对象的类只需与Output 接口祸合,而不是与具体的实现类祸合。即使系统中有很多类使用了Printer 对象,只要OutputFactory类的getOutput()方法生成的Output 对象是BetterPrinter 对象,则它们全部都会改为使用BetterPrinter 对象, 而所有程序无须修改, 只需要修改OutputFacto可工厂类的getOutputO方法实现即可。
2. 命令模式
考虑这样一种场景: 某个方法需要完成某一个行为, 但这个行为的具体实现无法确定,必须等到执行该方法时才可以确定。具体一点: 假设有个方法需要遍历某个数组的数组元素, 但无法确定在遍历数组元素时如何处理这些元素, 需要在调用该方法时指定具体的处理行为。
这个要求看起来有点奇怪: 这个方法不仅需要普通数据可以变化, 甚至还有方法执行体也需要变化,难道需要把" 处理行为"作为一个参数传入该方法?
对于这样一个需求,必须把" 处理行为"作为参数传入该方法,这个"处理行为"用编程来实现就是一段代码。那如何把这段代码传入该方法呢?
可以考虑使用一个Command 接口来定义一个方法,用这个方法来封装"处理行为"。下面是该Command 接口的代码。
public interface Command { // 接口里定义的process()方法用于封装“处理行为” void process(int[] target); }
上面的Command 接口里定义了一个process()方法, 这个方法用于封装"处理行为",但这个方法没有方法体一一因为现在还无法确定这个处理行为。
下面是需要处理数组的处理类,在这个处理类中包含一个process()方法,这个方法无法确定处理数组的处理行为,所以定义该方法时使用了一个Command 参数,这个Command 参数负责对数组的处理行为。该类的程序代码如下。
public class ProcessArray { public void process(int[] target , Command cmd) { cmd.process(target); } }
通过一个Cornmand 接口,就实现了让ProcessArray 类和具体"处理行为"的分离,程序使用Cornmand接口代表了对数组的处理行为。Cornmand 接口也没有提供真正的处理, 只有等到需要调用ProcessArray对象的process()方法时, 才真正传入一个Cornmand 对象,才确定对数组的处理行为。
下面程序示范了对数组的两种处理方式。
public class CommandTest { public static void main(String[] args) { ProcessArray pa = new ProcessArray(); int[] target = {3, -4, 6, 4}; // 第一次处理数组,具体处理行为取决于PrintCommand pa.process(target , new PrintCommand()); System.out.println("------------------"); // 第二次处理数组,具体处理行为取决于AddCommand pa.process(target , new AddCommand()); } }
下面分别是PrintCornmand 类和AddCornmand 类的代码。
public class PrintCommand implements Command { public void process(int[] target) { for (int tmp : target ) { System.out.println("迭代输出目标数组的元素:" + tmp); } } }
public class AddCommand implements Command { public void process(int[] target) { int sum = 0; for (int tmp : target ) { sum += tmp; } System.out.println("数组元素的总和是:" + sum); } }
对于PrintCommand 和AddCommand 两个实现类而言,实际有意义的部分就是process(int[] target)方法, 该方法的方法体就是传入ProcessArray 类里的process()方法的" 处理行为", 通过这种方式就可实现process()方法和"处理行为"的分离。
6.7 内部类
大部分时候,类被定义成一个独立的程序单元。在某些情况下, 也会把一个类放在另一个类的内部定义, 这个定义在其他类内部的类就被称为内部类(有的地方也叫嵌套类),包含内部类的类也被称为外部类(有的地方也叫宿主类) 。Java 从JDK 1.1 开始引入内部类,内部类主要有如下作用。
- 内部类提供了更好的封装,可以把内部类隐藏在外部类之内,不允许同一个包中的其他类访问该类。假设需要创建Cow 类, Cow 类需要组合一个CowLeg 对象, CowLeg 类只有在Cow 类里才有效,离开了Cow 类之后没有任何意义。在这种情况下, 就可把CowLeg 定义成Cow 的内部类,不允许其他类访问CowLeg 。
- 内部类成员可以直接访问外部类的私有数据,因为内部类被当成其外部类成员,同一个类的成员之间可以互相访问。但外部类不能访问内部类的实现细节,例如内部类的成员变量。
- 匿名内部类适合用于创建那些仅需要一次使用的类。对于前面介绍的命令模式,当需要传入一个Command 对象时,重新专门定义PrintCommand 和AddCommand 两个实现类可能没有太大的意义, 因为这两个实现类可能仅需要使用一次。在这种情况下, 使用匿名内部类将更方便。
从语法角度来看,定义内部类与定义外部类的语法大致相同,内部类除需要定义在其他类里面之外,还存在如下两点区别。
- 内部类比外部类可以多使用三个修饰符: private 、protected 、statÍc一一外部类不可以使用这三个修饰符。
- 非静态内部类不能拥有静态成员。
6.7.1 非静态内部类
定义内部类非常简单,只要把一个类放在另一个类内部定义即可。此处的" 类内部"包括类中的任何位置,甚至在方法中也可以定义内部类(方法里定义的内部类被称为局部内部类) 。内部类定义语法格式如下:
public class OuterClass { // 此处可以定义内部类 }
大部分时候,内部类都被作为成员内部类定义,而不是作为局部内部类。成员内部类是一种与成员变量、方法、构造器和初始化块相似的类成员: 局部内部类和匿名内部类则不是类成员。
成员内部类分为两种: 静态内部类和非静态内部类,使用static 修饰的成员内部类是静态内部类,没有使用statÍc 修饰的成员内部类是非静态内部类。
前面经常看到同一个Java 源文件里定义了多个类,那种情况不是内部类,它们依然是两个互相独立的类。例如下面程序:
// 下面A 、B 两个空类互相独立, 没有谁是谁的内部类 class A{} public class B{ }
上面两个类定义虽然写在同一个源文件中,但它们互相独立,没有谁是谁的内部类这种关系。内部类一定是放在另一个类的类体部分(也就是类名后的花括号部分)定义。
因为内部类作为其外部类的成员, 所以可以使用任意访问控制符如private 、protected 和public 等修饰。
下面程序在Cow 类里定义了一个CowLeg 非静态内部类,并在CowLeg 类的实例方法中直接访问Cow 的private 访问权限的实例变量。
public class Cow { private double weight; // 外部类的两个重载的构造器 public Cow(){} public Cow(double weight) { this.weight = weight; } // 定义一个非静态内部类 private class CowLeg { // 非静态内部类的两个实例变量 private double length; private String color; // 非静态内部类的两个重载的构造器 public CowLeg(){} public CowLeg(double length , String color) { this.length = length; this.color = color; } // 下面省略length、color的setter和getter方法 public void setLength(double length) { this.length = length; } public double getLength() { return this.length; } public void setColor(String color) { this.color = color; } public String getColor() { return this.color; } // 非静态内部类的实例方法 public void info() { System.out.println("当前牛腿颜色是:" + color + ", 高:" + length); // 直接访问外部类的private修饰的成员变量 System.out.println("本牛腿所在奶牛重:" + weight); //① } } public void test() { CowLeg cl = new CowLeg(1.12 , "黑白相间"); cl.info(); } public static void main(String[] args) { Cow cow = new Cow(378.9); cow.test(); } }
当在非静态内部类的方法内访问某个变量时,系统优先在该方法内查找是否存在该名字的局部变量,如果存在就使用该变量;如果不存在,则到该方法所在的内部类中查找是否存在该名字的成员变量, 如果存在则使用该成员变量: 如果不存在,则到该内部类所在的外部类中查找是否存在该名字的成员变量,如果存在则使用该成员变量: 如果依然不存在, 系统将出现编译错误: 提示找不到该变量。
因此,如果外部类成员变量、内部类成员变量与内部类里方法的局部变量同名,则可通过使用this 、外部类类名.this 作为限定来区分。如下程序所示。
public class DiscernVariable { private String prop = "外部类的实例变量"; private class InClass { private String prop = "内部类的实例变量"; public void info() { String prop = "局部变量"; // 通过 外部类类名.this.varName 访问外部类实例变量 System.out.println("外部类的实例变量值:" + DiscernVariable.this.prop); // 通过 this.varName 访问内部类实例的变量 System.out.println("内部类的实例变量值:" + this.prop); // 直接访问局部变量 System.out.println("局部变量的值:" + prop); } } public void test() { InClass in = new InClass(); in.info(); } public static void main(String[] args) { new DiscernVariable().test(); } }
非静态内部类的成员可以访问外部类的private 成员,但反过来就不成立了。非静态内部类的成员只在非静态内部类范围内是可知的, 并不能被外部类直接使用。如果外部类需要访问非静态内部类的成员,则必须显式创建非静态内部类对象来调用访问其实例成员。下面程序示范了这个规则。
public class Outer { private int outProp = 9; class Inner { private int inProp = 5; public void acessOuterProp() { // 非静态内部类可以直接访问外部类的private成员变量 System.out.println("外部类的outProp值:" + outProp); } } public void accessInnerProp() { // 外部类不能直接访问非静态内部类的实例变量, // 下面代码出现编译错误 // System.out.println("内部类的inProp值:" + inProp); // 如需访问内部类的实例变量,必须显式创建内部类对象 System.out.println("内部类的inProp值:" + new Inner().inProp); } public static void main(String[] args) { // 执行下面代码,只创建了外部类对象,还未创建内部类对象 Outer out = new Outer(); //① out.accessInnerProp(); } }
根据静态成员不能访问非静态成员的规则, 外部类的静态方法、静态代码块不能访问非静态内部类,包括不能使用非静态内部类定义变量、创建实例等。总之, 不允许在外部类的静态成员中直接使用非静态内部类。如下程序所示。
public class StaticTest { // 定义一个非静态的内部类,是一个空类 private class In{} // 外部类的静态方法 public static void main(String[] args) { // 下面代码引发编译异常,因为静态成员(main()方法) // 无法访问非静态成员(In类) new In(); } }
Java 不允许在非静态内部类里定义静态成员。下面程序示范了非静态内部类里包含静态成员将引发编译错误。
public class InnerNoStatic { private class InnerClass { /* 下面三个静态声明都将引发如下编译错误: 非静态内部类不能有静态声明 */ static { System.out.println("=========="); } private static int inProp; private static void test(){} } }
非静态内部类里不能有静态方法、静态成员变量、静态初始化块,所以上面三个静态声明都会引发错误。
6.7.2 静态内部类
如果使用static 来修饰一个内部类,则这个内部类就属于外部类本身,而不属于外部类的某个对象。因此使用static修饰的内部类被称为类内部类,有的地方也称为静态内部类。
静态内部类可以包含静态成员, 也可以包含非静态成员。根据静态成员不能访问非静态成员的规则,静态内部类不能访问外部类的实例成员,只能访问外部类的类成员。即使是静态内部类的实例方法也不能访问外部类的实例成员,只能访问外部类的静态成员。下面程序就演示了这条规则。
public class StaticInnerClassTest { private int prop1 = 5; private static int prop2 = 9; static class StaticInnerClass { // 静态内部类里可以包含静态成员 private static int age; public void accessOuterProp() { // 下面代码出现错误: // 静态内部类无法访问外部类的实例变量 // System.out.println(prop1); // 下面代码正常 System.out.println(prop2); } } }
上面程序中粗体字代码行定义了一个静态成员变量,因为这个静态成员变量处于静态内部类中,所以完全没有问题。StaticInnerClass 类里定义了一个accessOuterProp()方法,这是一个实例方法,但依然不能访问外部类的prop1成员变量, 因为这是实例变量: 但可以访问prop2 , 因为它是静态成员变量。
静态内部类是外部类的一个静态成员,因此外部类的所有方法、所有初始化块中可以使用静态内部类来定义变量、创建对象等。
外部类依然不能直接访问静态内部类的成员,但可以使用静态内部类的类名作为调用者来访问静态内部类的类成员,也可以使用静态内部类对象作为调用者来访问静态内部类的实例成员。下面程序示范了这条规则。
public class AccessStaticInnerClass { static class StaticInnerClass { private static int prop1 = 5; private int prop2 = 9; } public void accessInnerProp() { // System.out.println(prop1); // 上面代码出现错误,应改为如下形式: // 通过类名访问静态内部类的类成员 System.out.println(StaticInnerClass.prop1); // System.out.println(prop2); // 上面代码出现错误,应改为如下形式: // 通过实例访问静态内部类的实例成员 System.out.println(new StaticInnerClass().prop2); } }
除此之外, Java 还允许在接口里定义内部类,接口里定义的内部类默认使用public static 修饰,也就是说, 接口内部类只能是静态内部类。
如果为接口内部类指定访问控制符,则只能指定public 访问控制符; 如果定义接口内部类时省略访问控制符,则该内部类默认是public 访问控制权限。
6.7.3 使用内部类
定义类的主要作用就是定义变量、创建实例和作为父类被继承。定义内部类的主要作用也如此,但使用内部类定义变量和创建实例则与外部类存在一些小小的差异。下面分三种情况讨论内部类的用法。
1. 在外部类内部使用内部类
从前面程序中可以看出,在外部类内部使用内部类时,与平常使用普通类没有太大的区别。一样可以直接通过内部类类名来定义变量,通过new 调用内部类构造器来创建实例。
唯一存在的一个区别是:不要在外部类的静态成员(包括静态方法和静态初始化块)中使用非静态内部类,因为静态成员不能访问非静态成员。
在外部类内部定义内部类的子类与平常定义子类也没有太大的区别。
2 . 在外部类以外使用非静态内部类
如果希望在外部类以外的地方访问内部类(包括静态和非静态两种) ,则内部类不能使用private访问控制权限, private 修饰的内部类只能在外部类内部使用。对于使用其他访问控制符修饰的内部类, 则能在访问控制符对应的访问权限内使用。
- 省略访问控制符的内部类,只能被与外部类处于同一个包中的其他类所访问。
- 使用protected 修饰的内部类,可被与外部类处于同一个包中的其他类和外部类的子类所访问。
- 使用public 修饰的内部类,可以在任何地方被访问。
在外部类以外的地方定义内部类(包括静态和非静态两种)变量的语法格式如下:
OuterClass. InnerClass varName
从上面语法格式可以看出,在外部类以外的地方使用内部类时,内部类完整的类名应该是OuterClass.InnerClass 。如果外部类有包名,则还应该增加包名前缀。
由于非静态内部类的对象必须寄生在外部类的对象里,因此创建非静态内部类对象之前,必须先创建其外部类对象。在外部类以外的地方创建非静态内部类实例的语法如下:
OuterInstance.new InnerConstructor()
从上面语法格式可以看出. 在外部类以外的地方创建非静态内部类实例必须使用外部类实例和new来调用非静态内部类的构造器。下面程序示范了如何在外部类以外的地方创建非静态内部类的对象,并把它赋给非静态内部类类型的变量。
class Out { // 定义一个内部类,不使用访问控制符, // 即只有同一个包中其他类可访问该内部类 class In { public In(String msg) { System.out.println(msg); } } } public class CreateInnerInstance { public static void main(String[] args) { Out.In in = new Out().new In("测试信息"); /* 上面代码可改为如下三行代码: 使用OutterClass.InnerClass的形式定义内部类变量 Out.In in; 创建外部类实例,非静态内部类实例将寄存在该实例中 Out out = new Out(); 通过外部类实例和new来调用内部类构造器创建非静态内部类实例 in = out.new In("测试信息"); */ } }
上面程序中粗体代码行创建了一个非静态内部类的对象。从上面代码可以看出,非静态内部类的构造器必须使用外部类对象来调用。
如果需要在外部类以外的地方创建非静态内部类的子类,则尤其要注意上面的规则:非静态内部类的构造器必须通过其外部类对象来调用。
当创建一个子类时,子类构造器总会调用父类的构造器,因此在创建非静态内部类的子类时,必须保证让子类构造器可以调用非静态内部类的构造器,调用非静态内部类的构造器时,必须存在一个外部类对象。下面程序定义了一个子类继承了Out 类的非静态内部类In类。
public class SubClass extends Out.In { //显示定义SubClass的构造器 public SubClass(Out out) { //通过传入的Out对象显式调用In的构造器 out.super("hello"); } }
上面代码中粗体代码行看起来有点奇怪,其实很正常:非静态内部类In 类的构造器必须使用外部类对象来调用,代码中super 代表调用In类的构造器,而out 则代表外部类对象(上面的Out、In两个类直接来自于前一个CreateInnerInstance.java ) 。
从上面代码中可以看出,如果需要创建SubClass 对象时,必须先创建一个Out 对象。这是合理的,因为SubClass 是非静态内部类In类的子类,非静态内部类In对象里必须有一个对Out 对象的引用,其子类SubClass 对象里也应该持有对Out 对象的引用。当创建SubClass 对象时传给该构造器的Out 对象,就是SubClass 对象里Out 对象引用所指向的对象。
非静态内部类In对象和SubClass 对象都必须持有指向Outer对象的引用,区别是创建两种对象时传入Out 对象的方式不同2 当创建非静态内部类In类的对象时,必须通过Outer 对象来调用new关键宇:当创建SubClass 类的对象时,必须使用Outer 对象作为调用者来调用In类的构造器。
3. 在外部类以外使用静态内部类
因为静态内部类是外部类类相关的,因此创建静态内部类对象时无须创建外部类对象。在外部类以外的地方创建静态内部类实例的语法如下:
new OuterClass. InnerConstructor ()
下面程序示范了如何在外部类以外的地方创建静态内部类的实例。
class StaticOut { // 定义一个静态内部类,不使用访问控制符, // 即同一个包中其他类可访问该内部类 static class StaticIn { public StaticIn() { System.out.println("静态内部类的构造器"); } } } public class CreateStaticInnerInstance { public static void main(String[] args) { StaticOut.StaticIn in = new StaticOut.StaticIn(); /* 上面代码可改为如下两行代码: 使用OutterClass.InnerClass的形式定义内部类变量 StaticOut.StaticIn in; 通过new来调用内部类构造器创建静态内部类实例 in = new StaticOut.StaticIn(); */ } }
从上面代码中可以看出, 不管是静态内部类还是非静态内部类,它们声明变量的语法完全一样。区别只是在创建内部类对象时, 静态内部类只需使用外部类即可调用构造器,而非静态内部类必须使用外部类对象来调用构造器。
因为调用静态内部类的构造器时无须使用外部类对象, 所以创建静态内部类的子类也比较简单, 下面代码就为静态内部类StaticIn 类定义了一个空的子类。
public class StaticSubClass extends StaticOut.StaticIn {}
从上面代码中可以看出, 当定义一个静态内部类时,其外部类非常像一个包空间。
6.7.4 局部内部类
如果把一个内部类放在方法里定义,则这个内部类就是一个局部内部类, 局部内部类仅在该方法里有效。由于局部内部类不能在外部类的方法以外的地方使用,因此局部内部类也不能使用访问控制符和static 修饰符修饰。
如果需要用局部内部类定义变量、创建实例或派生子类,那么都只能在局部内部类所在的方法内进行。
public class LocalInnerClass { public static void main(String[] args) { // 定义局部内部类 class InnerBase { int a; } // 定义局部内部类的子类 class InnerSub extends InnerBase { int b; } // 创建局部内部类的对象 InnerSub is = new InnerSub(); is.a = 5; is.b = 8; System.out.println("InnerSub对象的a和b实例变量是:" + is.a + "," + is.b); } }
6.7.5 Java 8 改进的匿名内部类
匿名内部类适合创建那种只需要一次使用的类,例如前面介绍命令模式时所需要的Command 对象。匿名内部类的语法有点奇怪,创建匿名内部类时会立即创建一个该类的实例, 这个类定义立即消失, 匿名内部类不能重复使用。
定义匿名内部类的格式如下:
new 实现接口() | 父类构造器(实参列表) { //匿名内部类的类体部分 }
从上面定义可以看出, 匿名内部类必须继承一个父类,或实现一个接口, 但最多只能继承一个父类,或实现一个接口。
关于匿名内部类还有如下两条规则。
- 匿名内部类不能是抽象类,因为系统在创建匿名内部类时,会立即创建匿名内部类的对象。因此不允许将匿名内部类定义成抽象类。
- 匿名内部类不能定义构造器。由于匿名内部类没有类名,所以无法定义构造器,但匿名内部类可以定义初始化块, 可以通过实例初始化块来完成构造器需要完成的事情。
最常用的创建匿名内部类的方式是需要创建某个接口类型的对象,如下程序所示。
interface Product { public double getPrice(); public String getName(); } public class AnonymousTest { public void test(Product p) { System.out.println("购买了一个" + p.getName() + ",花掉了" + p.getPrice()); } public static void main(String[] args) { AnonymousTest ta = new AnonymousTest(); // 调用test()方法时,需要传入一个Product参数, // 此处传入其匿名实现类的实例 ta.test(new Product() { public double getPrice() { return 567.8; } public String getName() { return "AGP显卡"; } }); } }
上面程序中的AnonymousTest 类定义了一个test()方法,该方法需要一个Product 对象作为参数,但Product 只是一个接口,无法直接创建对象, 因此此处考虑创建一个Product 接口实现类的对象传入该方法一一如果这个Product 接口实现类需要重复使用, 则应该将该实现类定义成一个独立类:如果这个Product 接口实现类只需一次使用,则可采用上面程序中的方式,定义一个匿名内部类。
正如上面程序中看到的,定义医名内部类无须class 关键字,而是在定义医名内部类时直接生成该匿名内部类的对象。上面粗体字代码部分就是匿名内部类的类体部分。
由于匿名内部类不能是抽象类,所以匿名内部类必须实现它的抽象父类或者接口里包含的所有抽象方法。
当通过实现接口来创建匿名内部类时, 匿名内部类也不能显式创建构造器,因此医名内部类只有一个隐式的无参数构造器,故new 接口名后的括号里不能传入参数值。
但如果通过继承父类来创建匿名内部类时, 匿名内部类将拥有和父类相似的构造器,此处的相似指的是拥有相同的形参列表。
abstract class Device { private String name; public abstract double getPrice(); public Device(){} public Device(String name) { this.name = name; } // 此处省略了name的setter和getter方法 public void setName(String name) { this.name = name; } public String getName() { return this.name; } } public class AnonymousInner { public void test(Device d) { System.out.println("购买了一个" + d.getName() + ",花掉了" + d.getPrice()); } public static void main(String[] args) { AnonymousInner ai = new AnonymousInner(); // 调用有参数的构造器创建Device匿名实现类的对象 ai.test(new Device("电子示波器") { public double getPrice() { return 67.8; } }); // 调用无参数的构造器创建Device匿名实现类的对象 Device d = new Device() { // 初始化块 { System.out.println("匿名内部类的初始化块..."); } // 实现抽象方法 public double getPrice() { return 56.2; } // 重写父类的实例方法 public String getName() { return "键盘"; } }; ai.test(d); } }
上面程序创建了一个抽象父类Device 类,这个抽象父类里包含两个构造器: 一个无参数的, 一个有参数的。当创建以Device 为父类的匿名内部类时, 既可以传入参数(如上面程序中第一段粗体字部分),代表调用父类带参数的构造器:也可以不传入参数(如上面程序中第二段粗体字部分),代表调用父类无参数的构造器。
当创建匿名内部类时,必须实现接口或抽象父类里的所有抽象方法。如果有需要,也可以重写父类中的普通方法,如上面程序的第二段粗体字代码部分,匿名内部类重写了抽象父类Device 类的getName()方法,其中getName()方法并不是抽象方法。
在Java 8 之前, Java 要求被局部内部类、匿名内部类访问的局部变量必须使用final 修饰,从Java 8开始这个限制被取消了, Java 8 更加智能:如果局部变量被匿名内部类访问,那么该局部变量相当于自动使用了final 修饰。例如如下程序。
interface A { void test(); } public class ATest { public static void main(String[] args) { int age = 8; // ① // 下面代码将会导致编译错误 // 由于age局部变量被匿名内部类访问了,因此age相当于被final修饰了 // age = 2; A a = new A() { public void test() { // 在Java 8以前下面语句将提示错误:age必须使用final修饰 // 从Java 8开始,匿名内部类、局部内部类允许访问非final的局部变量 System.out.println(age); } }; a.test(); } }
6.8 Java 8 新增的Lambda 表达式
Lambda 表达式是Java 8 的重要更新,也是一个被广大开发者期待己久的新特性。Lambda 表达式支持将代码块作为方法参数, Lambda 表达式允许使用更简洁的代码来创建只有一个抽象方法的接口(这种接口被称为函数式接口)的实例。
6.8.1 Lambda 表达式入门
下面先使用匿名内部类来改写前面介绍的command 表达式的例子,改写后的程序如下。
public class CommandTest { public static void main(String[] args) { ProcessArray pa = new ProcessArray(); int[] array = {3, -4, 6, 4}; // 处理数组,具体处理行为取决于匿名内部类 pa.process(array , new Command() { public void process(int[] target) { int sum = 0; for (int tmp : target ) { sum += tmp; } System.out.println("数组元素的总和是:" + sum); } }); } }
前面己经提到, ProcessArray 类的process()方法处理数组时,希望可以动态传入一段代码作为具体的处理行为,因此程序创建了一个匿名内部类实例来封装处理行为。从上面代码可以看出,用于封装处理行为的关键就是实现程序中的粗体字方法。但为了向process()方法传入这段粗体字代码, 程序不得不使用匿名内部类的语法来创建对象。
Lambda 表达式完全可用于简化创建匿名内部类对象,因此可将上面代码改为如下形式。
public class CommandTest2 { public static void main(String[] args) { ProcessArray pa = new ProcessArray(); int[] array = {3, -4, 6, 4}; // 处理数组,具体处理行为取决于匿名内部类 pa.process(array , (int[] target)->{ int sum = 0; for (int tmp : target ) { sum += tmp; } System.out.println("数组元素的总和是:" + sum); }); } }
从上面程序中的粗体字代码可以看出,这段粗体宇代码与创建匿名内部类时需要实现的process(int[] target)方法完全相同,只是不需要new Xxx(){} 这种烦琐的代码,不需要指出重写的方法名字,也不需要给出重写的方法的返回值类型一一只要给出重写的方法括号以及括号里的形参列表即可。
从上面介绍可以看出,当使用Lambda 表达式代替匿名内部类创建对象时, Lambda 表达式的代码块将会代替实现抽象方法的方法体, Lambda 表达式就相当一个匿名方法。
从上面语法格式可以看出, Lambda 表达式的主要作用就是代替匿名内部类的烦琐语法。它由三部分组成。
- 形参列表。形参列表允许省略形参类型。如果形参列表中只有一个参数,甚至连形参列表的圆括号也可以省略。
- 箭头(-> )。必须通过英文中画线和大于符号组成。
- 代码块。如果代码块只包含一条语句, Lambda 表达式允许省略代码块的花括号,那么这条语句就不要用花括号表示语句结束。Lambda 代码块只有一条return 语句,甚至可以省略return关键字。Lambda 表达式需要返回值,而它的代码块中仅有一条省略了retum 的语句, Lambda 表达式会自动返回这条语句的值。
下面程序示范了Lambda 表达式的几种简化写法。
interface Eatable { void taste(); } interface Flyable { void fly(String weather); } interface Addable { int add(int a , int b); } public class LambdaQs { // 调用该方法需要Eatable对象 public void eat(Eatable e) { System.out.println(e); e.taste(); } // 调用该方法需要Flyable对象 public void drive(Flyable f) { System.out.println("我正在驾驶:" + f); f.fly("【碧空如洗的晴日】"); } // 调用该方法需要Addable对象 public void test(Addable add) { System.out.println("5与3的和为:" + add.add(5, 3)); } public static void main(String[] args) { LambdaQs lq = new LambdaQs(); // Lambda表达式的代码块只有一条语句,可以省略花括号。 lq.eat(()-> System.out.println("苹果的味道不错!")); // Lambda表达式的形参列表只有一个形参,省略圆括号 lq.drive(weather -> { System.out.println("今天天气是:" + weather); System.out.println("直升机飞行平稳"); }); // Lambda表达式的代码块只有一条语句,省略花括号 // 代码块中只有一条语句,即使该表达式需要返回值,也可以省略return关键字。 lq.test((a , b)->a + b); } }
上面程序中的第一段粗体字代码使用Lambda 表达式相当于不带形参的匿名方法,由于该Lambda表达式的代码块只有一行代码, 因此可以省略代码块的花括号:第二段粗体字代码使用Lambda 表达式相当于只带一个形参的医名方法,由于该Lambda 表达式的形参列表只有一个形参,因此省略了形参列表的圆括号:第三段粗体字代码的Lambda 表达式的代码块中只有一行语句,这行语句的返回值将作为该代码块的返回值。
上面程序中的第一处粗体字代码调用eat()方法,调用该方法需要一个Eatable 类型的参数,但实际传入的是Lambda 表达式;第二处粗体字代码调用drive()方法,调用该方法需要一个Flyable 类型的参数,但实际传入的是Lambda 表达式;第三处粗体字代码调用test()方法,调用该方法需要一个Addable类型的参数, 但实际传入的是Lambda 表达式。但上面程序可以正常编译、运行, 这说明Lambda 表达式实际上将会被当成一个"任意类型"的对象,到底需要当成何种类型的对象, 这取决于运行环境的需要。
6.8.2 Lambda 表达式与函数式接口
Lambda 表达式的类型,也被称为"目标类型( target type) ", Lambda 表达式的目标类型必须是"函数式接口( functional interface ) "。函数式接口代表只包含一个抽象方法的接口。函数式接口可以包含多个默认方法、类方法,但只能声明一个抽象方法。
如果采用匿名内部类语法来创建函数式接口的实例,则只需要实现一个抽象方法,在这种情况下即可采用Lambda 表达式来创建对象,该表达式创建出来的对象的目标类型就是这个函数式接口。查询Java 8的API 文档,可以发现大量的函数式接口,例如: Runnable 、ActionListener 等接口都是函数式接口。
由于Lambda 表达式的结果就是被当成对象, 因此程序中完全可以使用Lambda 表达式进行赋值,例如如下代码。
@FunctionalInterface interface FkTest { void run(); } public class LambdaTest { public static void main(String[] args) { // Runnable接口中只包含一个无参数的方法 // Lambda表达式代表的匿名方法实现了Runnable接口中唯一的、无参数的方法 // 因此下面的Lambda表达式创建了一个Runnable对象 Runnable r = () -> { for(int i = 0 ; i < 100 ; i ++) { System.out.println(); } }; // // 下面代码报错: 不兼容的类型: Object不是函数接口 // Object obj = () -> { // for(int i = 0 ; i < 100 ; i ++) // { // System.out.println(); // } // }; Object obj1 = (Runnable)() -> { for(int i = 0 ; i < 100 ; i ++) { System.out.println(); } }; // 同样的Lambda表达式可以被当成不同的目标类型,唯一的要求是: // Lambda表达式的形参列表与函数式接口中唯一的抽象方法的形参列表相同 Object obj2 = (FkTest)() -> { for(int i = 0 ; i < 100 ; i ++) { System.out.println(); } }; } }
从上面粗体宇代码可以看出, Lambda 表达式实现的是匿名方法因此它只能实现特定函数式接口中的唯一方法。这意味着Lambda 表达式有如下两个限制。
- Lambda 表达式的目标类型必须是明确的函数式接口。
- Lambda 表达式只能为函数式接口创建对象。Lambda 表达式只能实现一个方法, 因此它只能为只有一个抽象方法的接口(函数式接口〉创建对象。
为了保证Lambda 表达式的目标类型是一个明确的函数式接口,可以有如下三种常见方式。
- 将Lambda 表达式赋值给函数式接口类型的变量。
- 将Lambda 表达式作为函数式接口类型的参数传给某个方法。
- 使用函数式接口对Lambda 表达式进行强制类型转换。
Java 8 在java.util.function 包下预定义了大量函数式接口,典型地包含如下4 类接口。
- XxxFunction: 这类接口中通常包含一个apply()抽象方法,该方法对参数进行处理、转换(apply()方法的处理逻辑由Lambda 表达式来实现),然后返回一个新的值。该函数式接口通常用于对指定数据进行转换处理。
- XxxConsumer: 这类接口中通常包含一个accept()抽象方法,该方法与XxxFunction 接口中的apply()方法基本相似,也负责对参数进行处理,只是该方法不会返回处理结果。
- XxxxPredicate: 这类接口中通常包含一个test()抽象方法,该方法通常用来对参数进行某种判断(testO方法的判断逻辑由Lambda 表达式来实现),然后返回一个boolean 值。该接口通常用于判断参数是否满足特定条件,经常用于进行筛滤数据。
- XxxSupplier: 这类接口中通常包含一个getAsXxx()抽象方法,该力法不需要输入参数,该力法会按某种逻辑算法CgetAsXxx()方法的逻辑算法由Lambda 表达式来实现)返回一个数据。
综上所述,不难发现Lambda 表达式的本质很简单,就是使用简洁的语法来创建函数式接口的实现——这种语法避免了匿名内部类的烦琐。
6.8.3 方法引用与构造器引用
前面己经介绍过,如果Lambda 表达式的代码块只有一条代码,程序就可以省略Lambda 表达式中代码块的花括号。不仅如此,如果Lambda 表达式的代码块只有一条代码,还可以在代码块中使用方法引用和构造器引用。
方法引用和构造器引用可以让Lambda 表达式的代码块更加简洁。方法引用和构造器引用都需要使用两个英文冒号。Lambda 表达式支持如表6.2 所示的几种引用方式。
代码如下所示:
@FunctionalInterface interface Converter{ Integer convert(String from); } @FunctionalInterface interface MyTest { String test(String a , int b , int c); } @FunctionalInterface interface YourTest { JFrame win(String title); } public class MethodRefer { public static void main(String[] args) { // 下面代码使用Lambda表达式创建Converter对象 // Converter converter1 = from -> Integer.valueOf(from); // // 方法引用代替Lambda表达式:引用类方法。 // // 函数式接口中被实现方法的全部参数传给该类方法作为参数。 // Converter converter1 = Integer::valueOf; // Integer val = converter1.convert("99"); // System.out.println(val); // 输出整数99 // 下面代码使用Lambda表达式创建Converter对象 // Converter converter2 = from -> "fkit.org".indexOf(from); // // 方法引用代替Lambda表达式:引用特定对象的实例方法。 // // 函数式接口中被实现方法的全部参数传给该方法作为参数。 // Converter converter2 = "fkit.org"::indexOf; // Integer value = converter2.convert("it"); // System.out.println(value); // 输出2 // 下面代码使用Lambda表达式创建MyTest对象 // MyTest mt = (a , b , c) -> a.substring(b , c); // 方法引用代替Lambda表达式:引用某类对象的实例方法。 // 函数式接口中被实现方法的第一个参数作为调用者, // 后面的参数全部传给该方法作为参数。 // MyTest mt = String::substring; // String str = mt.test("Java I Love you" , 2 , 9); // System.out.println(str); // 输出:va I Lo // 下面代码使用Lambda表达式创建YourTest对象 // YourTest yt = (String a) -> new JFrame(a); // 构造器引用代替Lambda表达式。 // 函数式接口中被实现方法的全部参数传给该构造器作为参数。 YourTest yt = JFrame::new; JFrame jf = yt.win("我的窗口"); System.out.println(jf); } }
6.8.4 Lambda 表达式与匿名内部类的联系和区别
从前面介绍可以看出, Lambda 表达式是匿名内部类的一种简化,因此它可以部分取代匿名内部类的作用, Lambda 表达式与匿名内部类存在如下相同点。
- Lambda 表达式与匿名内部类一样, 都可以直接访问"effectively final" 的局部变量,以及外部类的成员变量(包括实例变量和类变量〉。
- Lambda 表达式创建的对象与匿名内部类生成的对象一样, 都可以直接调用从接口中继承的默认方法。
下面程序示范了Lambda 表达式与匿名内部类的相似之处。
@FunctionalInterface interface Displayable{ //定义一个抽象方法和默认方法 void display(); default int add(int a,int b) { return a+b; } } public class LambdaAndInner { private int age=12; private static String name="Java Study Center"; public void test() { String book="First Java"; Displayable dy=()->{ //访问局部变量 System.out.println("book:"+book); //访问外部变量 System.out.println("age:"+age); System.out.println("name:"+name); }; dy.display(); } public static void main(String[] args) { // TODO Auto-generated method stub LambdaAndInner inner=new LambdaAndInner(); inner.test(); } }
Lambda 表达式与匿名内部类主要存在如下区别。
- 匿名内部类可以为任意接口创建实例一一不管接口包含多少个抽象方法,只要匿名内部类实现所有的抽象方法即可;但Lambda 表达式只能为函数式接口创建实例。
- 匿名内部类可以为抽象类甚至普通类创建实例;但Lambda 表达式只能为函数式接口创建实例。
- 匿名内部类实现的抽象方法的方法体允许调用接口中定义的默认方法;但Lambda 表达式的代码块不允许调用接口中定义的默认方法。
6.8.5 使用Lambda 表达式调用Arrays 的类方法
前面介绍Array 类的功能时己经提到, Arrays 类的有些方法需要Comparator 、XxxOperator 、XxxFunction 等接口的实例,这些接口都是函数式接口,因此可以使用Lambda 表达式来调用Arrays 的方法。例如如下程序。
public class LambdaArrays { public static void main(String[] args) { String[] arr1 = new String[]{"java" , "fkava" , "fkit", "ios" , "android"}; Arrays.parallelSort(arr1, (o1, o2) -> o1.length() - o2.length()); System.out.println(Arrays.toString(arr1)); int[] arr2 = new int[]{3, -4 , 25, 16, 30, 18}; // left代表数组中前一个所索引处的元素,计算第一个元素时,left为1 // right代表数组中当前索引处的元素 Arrays.parallelPrefix(arr2, (left, right)-> left * right); System.out.println(Arrays.toString(arr2)); long[] arr3 = new long[5]; // operand代表正在计算的元素索引 Arrays.parallelSetAll(arr3 , operand -> operand * 5); System.out.println(Arrays.toString(arr3)); } }
6.9 枚举类
在某些情况下, 一个类的对象是有限而且固定的,比如季节类,它只有4 个对象:再比如行星类,目前只有8 个对象。这种实例有限而且固定的类,在Java 里被称为枚举类。
6.9.1 手动实现枚举类
在早期代码中,可能会直接使用简单的静态常量来表示枚举,例如如下代码:
public static final int SEASON_SPRING = 1; public static final int SEASON_ SUMMER = 2; public static final int SEASON_FALL = 3 ; public static final int SEASON_WINTER = 4;
这种定义方法简单明了,但存在如下几个问题。
- 类型不安全: 因为上面的每个季节实际上是一个int 整数,因此完全可以把一个季节当成一个int整数使用,例如进行加法运算SEASON SP阳NG + SEASON SUMMER ,这样的代码完全正常。
- 没有命名空间:当需要使用季节时,必须在SPRING 前使用SEASON 前缀,否则程序可能与其他类中的静态常量混淆。
- 打印输出的意义不明确: 当输出某个季节时,例如输出SEASON_SPRINT ,实际上输出的是1,这个1很难猜测它代表了春天。
但枚举又确实有存在的意义,因此早期也可采用通过定义类的方式来实现,可以采用如下设计方式。
- 通过private 将构造器隐藏起来。
- 把这个类的所有可能实例都使用public static final 修饰的类变量来保存。
- 如果有必要,可以提供一些静态方法,允许其他程序根据特定参数来获取与之匹配的实例。
- 使用枚举类可以使程序更加健壮,避免创建对象的随意性。
但通过定义类来实现枚举的代码量比较大,实现起来也比较麻烦, Java 从JDK 1.5 后就增加了对枚举类的支持。
6.9.2 枚举类入门
Java 5 新增了一个enum 关键宇(它与class 、interface 关键字的地位相同),用以定义枚举类。正如前面看到的,枚举类是一种特殊的类,它一样可以有自己的成员变量、方法,可以实现一个或者多个接口,也可以定义自己的构造器。一个Java 源文件中最多只能定义一个public 访问权限的枚举类,且该Java 源文件也必须和该枚举类的类名相同。
但枚举类终究不是普通类,它与普通类有如下简单区别。
- 枚举类可以实现一个或多个接口,使用enum 定义的枚举类默认继承了java .lang .Enum 类,而不是默认继承Object 类,因此枚举类不能显式继承其他父类。其中java.lang.Enum 类实现了java.lang.Serializable 和java.lang.Comparable 两个接口。
- 使用enum 定义、非抽象的枚举类默认会使用final 修饰, 因此枚举类不能派生子类。
- 枚举类的构造器只能使用private 访问控制符,如果省略了构造器的访问控制符,则默认使用private 修饰;如果强制指定访问控制符,则只能指定private 修饰符。
- 枚举类的所有实例必须在枚举类的第一行显式列出,否则这个枚举类永远都不能产生实例。列出这些实例时,系统会自动添加public static final 修饰,无须程序员显式添加。
枚举类默认提供了一个values()方法,该方法可以很方便地遍历所有的枚举值。
public enum SeasonEnum { // 在第一行列出4个枚举实例 SPRING,SUMMER,FALL,WINTER; } public class SeasonTest { public SeasonTest(Season s) { System.out.println(s.getName() + ",这真是一个"+ s.getDesc() + "的季节"); } public static void main(String[] args) { // 直接使用Season的FALL常量代表一个Season对象 new SeasonTest(Season.FALL); } }
前面己经介绍过,所有的枚举类都继承了java.lang.Enum 类,所以枚举类可以直接使用java.lang.Enum 类中所包含的方法。java. lang.Enum 类中提供了如下几个方法。
- int compareTo(E 0) : 该方法用于与指定枚举对象比较顺序,同一个枚举实例只能与相同类型的枚举实例进行比较。如果该枚举对象位于指定枚举对象之后, 则返回正整数; 如果该枚举对象位于指定枚举对象之前,则返回负整数,否则返回零。
- String name(): 返回此枚举实例的名称,这个名称就是定义枚举类时列出的所有枚举值之一。与此方法相比,大多数程序员应该优先考虑使用toString()方法, 因为toString()方法返回更加用户友好的名称。
- int ordinal(): 返回枚举值在枚举类中的索引值(就是枚举值在枚举声明中的位置, 第一个枚举值的索引值为零)。
- String toString(): 返回枚举常量的名称,与name 方法相似,但toString()方法更常用。
- public static <T extends Enum<T>> T valueOf(Class<T> enumType, String name) : 这是一个静态方法,用于返回指定枚举类中指定名称的枚举值。名称必须与在该枚举类中声明枚举值时所用的标识符完全匹配, 不允许使用额外的空白字符。
正如前面看到的, 当程序使用System. out.println(s)语句来打印枚举值时,实际上输出的是该枚举值的toString()方法, 也就是输出该枚举值的名字。
6.9.3 枚举类的成员变量、方法和构造器
枚举类也是一种类,只是它是一种比较特殊的类,因此它一样可以定义成员变量、方法和构造器。下面程序将定义一个Gender 枚举类, 该枚举类里包含了一个name 实例变量。
public enum Gender { MALE,FEMALE; // 定义一个public修饰的实例变量 public String name; } public class GenderTest { public static void main(String[] args) { // 通过Enum的valueOf()方法来获取指定枚举类的枚举值 Gender g = Enum.valueOf(Gender.class , "FEMALE"); // 直接为枚举值的name实例变量赋值 g.name = "女"; // 直接访问枚举值的name实例变量 System.out.println(g + "代表:" + g.name); } }
public enum Gender { MALE,FEMALE; private String name; public void setName(String name) { switch (this) { case MALE: if (name.equals("男")) { this.name = name; } else { System.out.println("参数错误"); return; } break; case FEMALE: if (name.equals("女")) { this.name = name; } else { System.out.println("参数错误"); return; } break; } } public String getName() { return this.name; } } public class GenderTest { public static void main(String[] args) { Gender g = Gender.valueOf("FEMALE"); g.setName("女"); System.out.println(g + "代表:" + g.getName()); // 此时设置name值时将会提示参数错误。 g.setName("男"); System.out.println(g + "代表:" + g.getName()); } }
public enum Gender { // 此处的枚举值必须调用对应构造器来创建 MALE("男"),FEMALE("女"); private final String name; // 枚举类的构造器只能使用private修饰 private Gender(String name) { this.name = name; } public String getName() { return this.name; } }
从上面程序中可以看出, 当为Gender 枚举类创建了一个Gender(String name)构造器之后,列出枚举值就应该采用粗体字代码来完成。也就是说, 在枚举类中列出枚举值时,实际上就是调用构造器创建枚举类对象,只是这里无须使用new 关键字,也无须显式调用构造器。前面列出枚举值时无须传入参数,甚至无须使用括号,仅仅是因为前面的枚举类包含无参数的构造器。
不难看出,上面程序中粗体字代码实际上等同于如下两行代码:
public static final Gender MALE = new Gender( " 男11 ) ; public static final Gender FEMALE = new Gender( " 女") ;
6.9.4 实现接口的枚举类
枚举类也可以实现一个或多个接口。与普通类实现一个或多个接口完全一样, 枚举类实现一个或多个接口时,也需要实现该接口所包含的方法。下面程序定义了一个GenderDesc 接口。
public interface GenderDesc { void info(); } public enum Gender implements GenderDesc { // 此处的枚举值必须调用对应构造器来创建 MALE("男") // 花括号部分实际上是一个类体部分 { public void info() { System.out.println("这个枚举值代表男性"); } }, FEMALE("女") { public void info() { System.out.println("这个枚举值代表女性"); } }; private final String name; // 枚举类的构造器只能使用private修饰 private Gender(String name) { this.name = name; } public String getName() { return this.name; } // 增加下面的info()方法,实现GenderDesc接口必须实现的方法 public void info() { System.out.println( "这是一个用于用于定义性别的枚举类"); } }
6.9.5 包含抽象方法的枚举类
假设有一个Operation 枚举类,它的4 个枚举值PLUS , MINUS, TIMES, DIVIDE 分别代表加、减、乘、除4 种运算, 该枚举类需要定义一个evalO方法来完成计算。
从上面描述可以看出, Operation 需要让PLUS 、MINUS 、TIMES 、DIVIDE 四个值对evalO方法各有不同的实现。此时可考虑为Operation 枚举类定义一个eval()抽象方法,然后让4 个枚举值分别为eval()提供不同的实现。例如如下代码。
public enum Operation { PLUS { public double eval(double x , double y) { return x + y; } }, MINUS { public double eval(double x , double y) { return x - y; } }, TIMES { public double eval(double x , double y) { return x * y; } }, DIVIDE { public double eval(double x , double y) { return x / y; } }; // 为枚举类定义一个抽象方法 // 这个抽象方法由不同的枚举值提供不同的实现 public abstract double eval(double x, double y); public static void main(String[] args) { System.out.println(Operation.PLUS.eval(3, 4)); System.out.println(Operation.MINUS.eval(5, 4)); System.out.println(Operation.TIMES.eval(5, 4)); System.out.println(Operation.DIVIDE.eval(5, 4)); } }
编译上面程序会生成5 个class 文件,其实Operation 对应一个class 文件,它的4 个匿名内部子类分别各对应一个class 文件。
枚举类里定义抽象方法时不能使用abstract 关键宇将枚举类定义成抽象类(因为系统自动会为它添加abstract 关键宇),但因为枚举类需要显式创建枚举值,而不是作为父类,所以定义每个枚举值时必须为抽象方法提供实现,否则将出现编译错误。
6.10 对象与垃圾回收
Java 的垃圾回收是Java 语言的重要功能之一。当程序创建对象、数组等引用类型实体时,系统都会在堆内存中为之分配一块内存区,对象就保存在这块内存区中, 当这块内存不再被任何引用变量引用时,这块内存就变成垃圾,等待垃圾回收机制进行回收。垃圾回收机制具有如下特征。
- 垃圾回收机制只负责回收堆内存中的对象,不会回收任何物理资源(例如数据库连接、网络IO等资源) 。
- 程序无法精确控制垃圾回收的运行,垃圾回收会在合适的时候进行。当对象永久性地失去引用后,系统就会在合适的时候回收它所占的内存。
- 在垃圾回收机制回收任何对象之前,总会先调用它的finalize()方法,该方法可能使该对象重新复活(让一个引用变量重新引用该对象) ,从而导致垃圾回收机制取消回收。
6.10.1 对象在内存中的状态
当一个对象在堆内存中运行时,根据它被引用变量所引用的状态,可以把它所处的状态分成如下三种。
- 可达状态: 当一个对象被创建后,若有一个以上的引用变量引用它,则这个对象在程序中处于可达状态,程序可通过引用变量来调用该对象的实例变量和方法。
- 可恢复状态:如果程序中某个对象不再有任何引用变量引用它,它就进入了可恢复状态。在这种状态下,系统的垃圾回收机制准备回收该对象所占用的内存,在回收该对象之前,系统会调用所有可恢复状态对象的finalize()方法进行资源清理。如果系统在调用finalize()方法时重新让一个引用变量引用该对象,则这个对象会再次变为可达状态;否则该对象将进入不可达状态。
-
不可达状态:当对象与所有引用变量的关联都被切断,且系统已经调用所有对象的finalize()方法后依然没有使该对象变成可达状态,那么这个对象将永久性地失去引用,最后变成不可达状态。只有当一个对象处于不可达状态时,系统才会真正回收该对象所占有的资源。
图6.7 显示了对象的三种状态的转换示意图。
6.10.2 强制垃圾回收
当一个对象失去引用后,系统何时调用它的ínalize()方法对它进行资源清理,何时它会变成不可达状态,系统何时回收它所占有的内存,对于程序完全透明。程序只能控制一个对象何时不再被任何引用变量引用, 绝不能控制它何时被回收。
程序无法精确控制Java 垃圾回收的时机,但依然可以强制系统进行垃圾回收一一这种强制只是通知系统进行垃圾回收,但系统是否进行垃圾回收依然不确定。大部分时候,程序强制系统垃圾回收后总会有一些效果。强制系统垃圾回收有如下两种方式。
- 调用System 类的gc()静态方法: System.gc()。
- 调用Runtime 对象的gc()实例方法: Runtime.getRuntime().gc()。
下面程序创建了4 个匿名对象, 每个对象创建之后立即进入可恢复状态,等待系统回收, 但直到程序退出,系统依然不会回收该资源。
public class GcTest { public static void main(String[] args) { for (int i = 0 ; i < 4; i++) { new GcTest(); } } public void finalize() { System.out.println("系统正在清理GcTest对象的资源..."); } }
编译、运行上面程序, 看不到任何输出,可见直到系统退出,系统都不曾调用GcTest 对象的finalize()方法。但如果将程序修改成如下形式:
public class GcTest { public static void main(String[] args) { for (int i = 0 ; i < 4; i++) { new GcTest(); // 下面两行代码的作用完全相同,强制系统进行垃圾回收 // System.gc(); Runtime.getRuntime().gc(); } } public void finalize() { System.out.println("系统正在清理GcTest对象的资源..."); } }
上面程序与前一个程序相比,只是增加了粗体字代码行, 此代码行强制系统进行垃圾回收。编译上面程序,使用如下命令来运行此程序:
java - verbose:gc GcTest
运行Java 命令时指定-verbose:gc 选项,可以看到每次垃坡回收后的提示信息。
6.10.3 finalize 方法
在垃圾回收机制回收某个对象所占用的内存之前,通常要求程序调用适当的方法来清理资源, 在没有明确指定清理资源的情况下, Java 提供了默认机制来清理该对象的资源, 这个机制就是finalize()方法。
该方法是定义在Object 类里的实例方法,方法原型为:
protected void finalize() throws Throwable
当finalize()方法返回后,对象消失,垃圾回收机制开始执行。方法原型中的throws Throwable 表示它可以抛出任何类型的异常。
任何Java 类都可以重写Object 类的finalize()方法,在该方法中清理该对象占用的资源。如果程序终止之前始终没有进行垃圾回收,则不会调用失去引用对象的finalize()方法来清理资源。垃圾回收机制何时调用对象的finalize()方法是完全透明的,只有当程序认为需要更多的额外内存时,垃圾回收机制才会进行垃圾回收。因此,完全有可能出现这样一种情形:某个失去引用的对象只占用了少量内存,而且系统没有产生严重的内存需求,因此垃圾回收机制并没有试图回收该对象所占用的资源,所以该对象的finalize()方法也不会得到调用。
finalize()方法具有如下4 个特点。
- 永远不要主动调用某个对象的finalize()方法,该方法应交给垃圾回收机制调用。
- finalize()方法何时被调用,是否被调用具有不确定性,不要把finalize()方法当成一定会被执行的方法。
- 当JVM执行可恢复对象的finalize()方法时,可能使该对象或系统中其他对象重新变成可达状态。
- 当JVM执行finalize()方法时出现异常时,垃圾回收机制不会报告异常,程序继续执行。
下面程序演示了如何在finalize()方法里复活自身, 并可通过该程序看出垃圾回收的不确定性。
public class FinalizeTest { private static FinalizeTest ft = null; public void info() { System.out.println("测试资源清理的finalize方法"); } public static void main(String[] args) throws Exception { // 创建FinalizeTest对象立即进入可恢复状态 new FinalizeTest(); // 通知系统进行资源回收 System.gc(); //① // 强制垃圾回收机制调用可恢复对象的finalize()方法 // Runtime.getRuntime().runFinalization(); //② System.runFinalization(); //③ ft.info(); } public void finalize() { // 让ft引用到试图回收的可恢复对象,即可恢复对象重新变成可达 ft = this; } }
上面程序中定义了一个FinalizeTest 类,重写了该类的finalize()方法, 在该方法中把需要清理的可恢复对象重新赋给a 引用变量,从而让该可恢复对象重新变成可达状态。
上面程序中的main()方法创建了一个FinalizeTest 类的匿名对象, 因为创建后没有把这个对象赋给任何引用变量,所以该对象立即进入可恢复状态。进入可恢复状态后,系统调用①号粗体字代码通知系统进行垃圾回收, ②号粗体字代码强制系统立即调用可恢复对象的finalize()方法,再次调用ft对象的info()方法。编译、运行上面程序, 看到ft的info()方法被正常执行。
如果取消①号粗体字代码,程序并没有通知系统开始执行垃圾回收(而且程序内存也没有紧张) ,因此系统通常不会立即进行垃圾回收,也就不会调用FinalizeTest对象的fmalize()方法,这样FinalizeTest 的白类变量将依然保持为null ,这样就导致了空指针异常。
上面程序中②号代码和③号代码都用于强制垃圾回收机制调用可恢复对象的finalize()方法,如果程序仅执行System.gc();代码,而不执行②号或③号粗体字代码一一由于JVM 垃圾回收机制的不确定性,JVM往往并不立即调用可恢复对象的finalize()方法,这样FinalizeTest 的ft类变量可能依然为null ,可能依然会导致空指针异常.
6.10.4 对象的软、弱和虚引用
对大部分对象而言,程序里会有一个引用变量引用该对象,这是最常见的引用方式。除此之外,java.lang.ref 包下提供了3 个类: SoftReference 、PhantomReference 和WeakReference ,它们分别代表了系统对对象的3 种引用方式:软引用、虚引用和弱引用。因此, Java 语言对对象的引用有如下4 种方式。
1. 强引用(StrongReference)
这是Java 程序中最常见的引用方式。程序创建一个对象,并把这个对象赋给一个引用变量,程序通过该引用变量来操作实际的对象,前面介绍的对象和数组都采用了这种强引用的方式。当一个对象被一个或一个以上的引用变量所引用时,它处于可达状态,不可能被系统垃圾回收机制回收。
2. 软引用(SoftReference)
软引用需要通过SoftReference 类来实现,当一个对象只有软引用时,它有可能被垃圾回收机制回收。对于只有软引用的对象而言,当系统内存空间足够时,它不会被系统回收,程序也可使用该对象;当系统内存空间不足时,系统可能会回收它。软引用通常用于对内存敏感的程序中。
3. 弱引用(WeakReference)
弱引用通过WeakReference 类实现,弱引用和软引用很像,但弱引用的引用级别更低。对于只有弱引用的对象而言,当系统垃圾回收机制运行时,不管系统内存是否足够,总会回收该对象所占用的内存。当然,并不是说当一个对象只有弱引用时,它就会立即被回收一一一正如那些失去引用的对象一样,必须等到系统垃圾回收机制运行时才会被回收。
4. 虚引用(PhantomReference)
虚引用通过PhantomReference 类实现,虚引用完全类似于没有引用。虚引用对对象本身没有太大影响,对象甚至感觉不到虚引用的存在。如果一个对象只有一个虚引用时,那么它和没有引用的效果大致相同。虚引用主要用于跟踪对象被垃圾回收的状态,虚引用不能单独使用,虚引用必须和引用队列( ReferenceQueue ) 联合使用。
上面三个引用类都包含了一个get()方法,用于获取被它们所引用的对象。
引用队列由java. lang.ref.ReferenceQueue 类表示,它用于保存被回收后对象的引用。当联合使用软引用、弱引用和引用队列时,系统在回收被引用的对象之后,将把被回收对象对应的引用添加到关联的引用队列中。与软引用和弱引用不同的是,虚引用在对象被释放之前,将把它对应的虚引用添加到它关联的引用队列中,这使得可以在对象被回收之前采取行动。
软引用和弱引用可以单独使用,但虚引用不能单独使用,单独使用虚引用没有太大的意义。虚引用的主要作用就是跟踪对象被垃圾回收的状态,程序可以通过检查与虚引用关联的引用队列中是否已经包含了该虚引用,从而了解虚引用所引用的对象是否即将被回收。
下面程序示范了弱引用所引用的对象被系统垃圾回收过程。
public class ReferenceTest { public static void main(String[] args) throws Exception { // 创建一个字符串对象 String str = new String("Java讲义"); // 创建一个弱引用,让此弱引用引用到"Java讲义"字符串 WeakReference wr = new WeakReference(str); //① // 切断str引用和"Java讲义"字符串之间的引用 str = null; //② // 取出弱引用所引用的对象 System.out.println(wr.get()); //③ // 强制垃圾回收 System.gc(); System.runFinalization(); // 再次取出弱引用所引用的对象 System.out.println(wr.get()); //④ } }
下面程序与上面程序基本相似,只是使用了虚引用来引用字符串对象,虚引用无法获取它引用的对象。下面程序还将虚引用和引用队列结合使用,可以看到被虚引用所引用的对象被垃圾回收后,虚引用将被添加到引用队列中。
public class PhantomReferenceTest { public static void main(String[] args) throws Exception { // 创建一个字符串对象 String str = new String("Java讲义"); // 创建一个引用队列 ReferenceQueue rq = new ReferenceQueue(); // 创建一个虚引用,让此虚引用引用到"Java讲义"字符串 PhantomReference pr = new PhantomReference (str , rq); // 切断str引用和"Java讲义"字符串之间的引用 str = null; // 取出虚引用所引用的对象,并不能通过虚引用获取被引用的对象,所以此处输出null System.out.println(pr.get()); //① // 强制垃圾回收 System.gc(); System.runFinalization(); // 垃圾回收之后,虚引用将被放入引用队列中 // 取出引用队列中最先进入队列中的引用与pr进行比较 System.out.println(rq.poll() == pr); //② } }
因为系统无法通过虚引用来获得被引用的对象,所以执行①处的输出语句时,程序将输出null ( 即使此时并未强制进行垃圾回收) 。当程序强制垃圾回收后,只有虚引用引用的字符串对象将会被垃圾回收,当被引用的对象被回收后,对应的虚引用将被添加到关联的引用队列中,因而将在②代码处看到输出true 。
使用这些引用类可以避免在程序执行期间将对象留在内存中。如果以软引用、弱引用或虚引用的方式引用对象,垃圾回收器就能够随意地释放对象。如果希望尽可能减小程序在其生命周期中所占用的内存大小时,这些引用类就很有用处。
必须指出:要使用这些特殊的引用类,就不能保留对对象的强引用:如果保留了对对象的强引用,就会浪费这些引用类所提供的任何好处。
由于垃圾回收的不确定性,当程序希望从软、弱引用中取出被引用对象时,可能这个被引用对象己经被释放了。
6.11 修饰符的适用范围
到目前为止,己经学习了Java 中的大部分修饰符,如访问控制符、static 和final 等。还有其他的一些修饰符将会在后面的章节里继续介绍,此处给出Java 修饰符适用范围总表(见表6.3 )。
在表6 .3 中,包访问控制符是一个特殊的修饰符,不用任何访问控制符的就是包访问控制。对于初始化块和局部成员而言,它们不能使用任何访问控制符,所以看起来像使用了包访问控制符。
strictfp 关键宇的含义是FP-strict ,也就是精确浮点的意思。在Java 虚拟机进行浮点运算时, 如果没有指定strictfp关键宇, Java 的编译器和运行时环境在浮点运算上不一定令人满意。一旦使用了strictfp来修饰类、接口或者方法时,那么在所修饰的范围内Java 的编译器和运行时环境会完全依照浮点规范IEEE-754 来执行。因此,如果想让浮点运算更加精确,就可以使用strictfp 关键字来修饰类、接口和方法。
native 关键宇主要用于修饰一个方法,使用native 修饰的方法类似于一个抽象方法。与抽象方法不同的是, native 方法通常采用C 语言来实现。如果某个方法需要利用平台相关特性,或者访问系统硬件等,则可以使用native 修饰该方法,再把该方法交给C 去实现。一旦Java 程序中包含了native 方法,这个程序将失去跨平台的功能。
在表6 . 3 列出的所有修饰符中, 4 个访问控制符是互斥的,最多只能出现其中之一。不仅如此, 还有abstract 和final永远不能同时使用; abstract 和static不能同时修饰方法,可以同时修饰内部类; abstract和private 不能同时修饰方法,可以同时修饰内部类。private 和final 同时修饰方法虽然语法是合法的,但没有太大的意义一一由于private 修饰的方法不可能被子类重写,因此使用final修饰没什么意义。
6.12 Java 9 的多版本JAR 包
JAR 文件的全称是Java Archive File ,意思就是Java 档案文件。通常JAR 文件是一种压缩文件,与常见的ZIP 压缩文件兼容,通常也被称为JAR 包。JAR 文件与ZIP 文件的区别就是在JAR 文件中默认包含了一个名为META-INF/MANIFEST.MF 的清单文件,这个清单文件是在生成JAR 文件时由系统自动创建的。
当开发了一个应用程序后,这个应用程序包含了很多类,如果需要把这个应用程序提供给别人使用,通常会将这些类文件打包成一个JAR 文件,把这个JAR 文件提供给别人使用。只要别人在系统的CLASSPATH 环境变量中添加这个JAR 文件,则Java 虚拟机就可以自动在内存中解压这个JAR 包,把这个JAR 文件当成一个路径,在这个路径中查找所需要的类或包层次对应的路径结构。
使用JAR 文件有以下好处。
- 安全。能够对JAR 文件进行数字签名,只让能够识别数字签名的用户使用里面的东西。
- 加快下载速度。在网上使用Applet 时, 如果存在多个文件而不打包, 为了能够把每个文件都下载到客户端,需要为每个文件单独建立一个HTTP 连接, 这是非常耗时的工作。将这些文件压缩成一个JAR 包,只要建立一次HTTP 连接就能够一次下载所有的文件。
- 压缩。使文件变小, JAR 的压缩机制和ZIP 完全相同。
- 包封装。能够让JAR 包里面的文件依赖于统一版本的类文件。
- 可移植性。JAR 包作为内嵌在Java 平台内部处理的标准,能够在各种平台上直接使用。
把一个JAR 文件添加到系统的CLASSPATH 环境变量中后, Java 将会把这个JAR 文件当成一个路径来处理。实际上JAR 文件就是一个路径, JAR 文件通常使用jar 命令压缩而成,当使用jar 命令压缩生成JAR 文件时,可以把一个或多个路径全部压缩成一个JAR 文件。
6.12.1 jar 命令详解
jar 是随JDK 自动安装的,在JDK 安装目录下的bin 目录中,Windows 下文件名为jar.exe , Linux 下文件名为jar 。
下面通过一些例子来说明jar 命令的用法。
1. 创建JAR 文件: jar cf test.jar -C dist/ .
该命令没有显示压缩过程,执行结果是将当前路径下的dist 路径下的全部内容生成一个test.jar 文件。如果当前目录中己经存在test.jar 文件,那么该文件将被覆盖。
2. 创建JAR 文件,井显示压缩过程: jar cvf test.jar -C dist/ .
该命令的结果与第1 个命令相同,但是由于v 参数的作用,显示出了打包过程.
3. 不使用清单文件: jar cvfM test.jar -C dist/ .
该命令的结果与第2 个命令类似,其中M 选项表明不生成清单文件。因此生成的test.jar 中没有包含META-INF/MANIFEST. MF 文件,打包过程的信息也略有差别。
4. 自定义清单文件内容: jar cvfm test.jar manifest.mf -C dist/ .
运行结果与第2 个命令相似,显示信息也相同,其中m 选项指定读取用户清单文件信息。因此在生成的JAR 包中清单文件META-INF/MANIFEST. MF 的内容有所不同,它会在原有清单文件基础上增加MANIFEST.MF 文件的内容。
当开发者向MANIFEST.MF清单文件中增加自己的内容时,就需要借助于自己的清单文件了,清单文件只是一个普通的文本文件,使用记事本编辑即可。清单文件的内容由如下格式的多个key-value对组成。
key:<空格>value
清单文件的内容格式要求如下。
- 每行只能定义一个key-value 对,每行的key-value 对之前不能有空格,即key-value 对必须顶格写。
- 每组key-value对之间以"'''(英文冒号后紧跟一个英文空格)分隔,少写了冒号或者空格都是错误的。
- 文件开头不能有空行。
- 文件必须以一个空行结束。
可以将上面文件保存在任意位置,以任意文件名存放。例如将上面文件保存在当前路径下,文件名为a.txt 。使用如下命令即可将清单文件中的key-value 对提取到META-INF/MANIFEST. MF 文件中。
jar cvfm test.jar a .txt - C dist/ .
5. 查看JAR 包内容: jar tf test.jar
在test.jar 文件已经存在的前提下,使用此命令可以查看test.jar 中的内容。
6. 查看JAR 包详细内容: jar tvf test.jar
该命令与第5 个命令基本相似,但它更详细。所以除显示第5 个命令中显示的内容外,还包括包内文件的详细信息。
7. 解压缩: jar xf test.jar
将test.jar 文件解压缩到当前目录下,不显示任何信息。
8. 带提示信息解压缩: jar xvf test.jar
解压缩效果与第7 个命令相同,但系统会显示解压过程的详细信息。
9. 更新JAR 文件: jar uf test.jar Hello.class
更新test.jar 中的Hello.class 文件。如果test.jar 中已有Hello. cIass 文件,则使用新的Hello .cIass 文件替换原来的Hello .cIass 文件:如果test.jar 中没有Hello. cIass 文件,则把新的Hello. cIass 文件添加到test.jar 文件中。
10. 更新时显示详细信息: jar uvf test.jar Hello.class
这个命令与第9 个命令相同,也用于更新test.jar 文件中的Hello.cIass 文件,但它会显示详细的压缩信息。
11. 创建多版本JAR 包: jar cvf test.jar -C dist7/ .一release 9 -C dist/ .
多版本JAR 包是JDK9 新增的功能,它允许在同一个JAR 包中包含针对多个Java 版本的cIass 文件。JDK9 为jar 命令增加了一个--release 选项,用于创建多版本JAR 包,该选项的参数值必须大于或等于9一一也就是说,只有Java 9 才能支持多版本JAR 包。