yeskery

面向对象的陷阱

面向对象是 Java 语言的重点,其它所有知识几乎都是以面向对象特征为基础的,而且面向对象特征本身的语法规则就非常多,而且非常细,因此这些都需要初学者花大量的时间来学习、记忆。如果开始掌握得不够全面,往往导致开发中遇到相关问题不明所以,到时候依然要花时间来掌握它们。

instanceof 运算符的陷阱

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

如果仅从 instanceof 运算符的介绍来看,这个运算符的用法其实非常简单。实际上使用该运算符往往并不简单,先看如下程序:

  1. public class InstanceofTest {
  2. public static void main(String[] args) {
  3. //声明hello时使用Object类,则hello的编译时类型是Object
  4. //Object是所有类的父类,但hello变量的实际类型是String
  5. Object hello = "hello";
  6. //String是Object类的子类,所以可以进行instanceof运算,返回 true
  7. System.out.println("字符串是否是Object类的实例:" + (hello instanceof Object));
  8. //返回true
  9. System.out.println("字符串是否是String类的实例:" + (hello instanceof String));
  10. //Math是Object的子类,所以可以进行instanceof运算
  11. //返回false
  12. System.out.println("字符串是否Math类的实例:" + (hello instanceof Math));
  13. //String实现了Comparable及接口,所以返回true
  14. System.out.println("字符串是否是Comparable接口的实例:" + (hello instanceof Comparable));
  15. //声明str时使用了String类,则str的编译时类型是String类型
  16. String str = "hello";
  17. //String类(编译时类型)既不是Math类,也不是Math类的父、子类
  18. //所以下面代码编译无法通过
  19. System.out.println("字符串是否是Math类的实例:" + (str instanceof Math));
  20. //String类(编译时类型)不是Serializable类,但它是Serializable类的子类
  21. //因此下面代码可以编译成功,输出true
  22. System.out.println("字符串是否是Serializable类的实例:" + (str instanceof Serializable));
  23. }
  24. }

上面的程序在代码 System.out.println("字符串是否是Math类的实例:" + (str instanceof Math)); 处无法通过编译,会提示“不可转换的类型”编译错误。

很明显,改程序的这个地方并不能使用 instanceof 运算符。根据 Java 语言规范,使用 instanceof 运算符有一个限制:instanceof 运算符前面操作数的编译时类型必须是如下 3 种情况:

  • 要么与后面的类相同;
  • 要么是后面类的父类;
  • 要么是后面类型的子类。

如果前面操作数的编译时类型与后面的类型没有任何关系,程序将没法通过编译。因此,当使用 instanceof 运算符的时候,应尽量从编译、运行两个截断来考虑它:如果 instanceof 运算符使用不当,程序编译时就会抛出异常;当使用 instanceof 运算符编译通过后,才能考虑它的运算结果是 true,还是 false。

一旦 intanceof 运算符通过了编译,程序进入运行阶段。instanceof 运算符返回的结果与前一个操作数(引用变量)实际引用的对象的类型有关,如果它实际引用的对象是第二个操作数的实例,或者是第二个操作数的子类、实现类的实例,instanceof 运算的结果返回 true,否则返回 false。

在极端情况下,instanceof 前一个操作数所引用对象的实际类型就是后面的类型,但只要它的编译时类型既不是第二个操作数的类型,也不是第二个操作数的父类、子类名程序就没法通过编译。示例如下:

  1. public class InstanceofTest2 {
  2. public static void main(String[] args) {
  3. Object str = "疯狂 Java 讲义";
  4. //执行强制类型转换
  5. //让Math以你用原来str引用的对象
  6. Math math = (Math)str;
  7. System.out.println("字符串是否是String的实例:" + (math instanceof String));
  8. }
  9. }

编译上面的程序,会看到编译器并没有在 Math math = (Math)str; 处提示异常,而是在 System.out.println("字符串是否是String的实例:" + (math instanceof String)); 代码处提示了“不可转换的类型”编译错误。这看上去有点不可思议:math 实际引用的就是 String 对象,程序用 instanceof 判断它的类型应该输出 true————没错,如果程序可以通过编译,最后确实应该输出 true。

问题是,当编译器编辑 Java 程序时,编译器无法检查引用变量实际引用对象的类型,它只检查该变量的编译类型。对于 math instanceof String 而言,math 的编译时类型是 Math,Math 既不是 String 类型,也不是 String 类型的父类、也不是 String 类型的子类,因此程序没法通过编译。至于 math 实际引用对象的类型是什么,编译器并不关心,编译阶段也没法关心。

至于在 Math math = (Math)str; 代码处为何没有出现编译错误,这和强制转型的机制有关。对于 Java 的强制转型而言,也可以分为编译、运行两个阶段来分析它。

  • 在编译阶段、强制转型要求被转型变量的编译时类型必须是如下 3 种情况之一:
    • 被转型变量的编译时类型与目标类型相同;
    • 被转型变量的编译时类型是目标类型父类;
    • 在转型变量的编译时类型是目标类型子类,这种情况下可以自动向上转型,无需强制转换。

如果被转型变量的编译时类型与目标没有任何继承关系,编译器将提示编译错误。通过上面分析可以看出,强制转型阶段只关心引用变量的编译时类型,至于该引用变量实际引用对象的类型,编译器不关心,也没法关心。

  • 在运行阶段,被转型变量所引用对象的实际类型必须是目标类型的实例,或者是目标类型的子类、实现类的实例,否则在运行时将引发 ClassCastException 异常。

从上面分析可以看出,对于 Math math = (Math)str; 代码,程序编译时不会出现错误,因为 str 引用变量的编译时类型是 Object,它是 Math 类的父类。至于 str 所引用对象的实际类型是什么,编译器并不会关心,因此这行代码完全可以通过编译,但着该行代码将引发 ClassCastException。

  1. public class ConversionTest {
  2. public static void main(String[] args) {
  3. Object obj = "hello";
  4. //obj变量的编译类型为Object,是String类型的父类,可以强制类型转换
  5. //而且obj实际变量上引用也是String对象,所以运行时也正常
  6. String objStr = (String)obj;
  7. System.out.println(objStr);
  8. //定义一个objPri变量,编译类型为Object,实际类型为Integer
  9. Object objPri = new Integer(5);
  10. //objPri变量的编译类型为Object,是String类型的父类,可以强制转换
  11. //而且objPri变量实际引用的是Integer对象
  12. //所以下面代码运行时引发ClassCastException异常
  13. String str = (String)objPri;
  14. String s = "疯狂Java类型";
  15. //因为s的编译时类型是String,String不是Math类型
  16. //String也不是Math的子类,也不是Math的父类,所以下面代码将导致编译错误
  17. Math m = (Math)s;
  18. }
  19. }

关于 instanceof 还有一个比较隐蔽的陷阱,示例如下:

  1. public class NullIntanceof {
  2. public static void main(String[] args) {
  3. String s = null;
  4. System.out.println("null是否是String类的示例:" + (s instanceof String));
  5. }
  6. }

尝试编译运行上面的程序,程序输出 false。虽然 null 可以作为所有引用类型变量的值,但对于 s 引用变量而言,它实际并未引用一个真正的 String 对象,因此程序输出 false。

使 null 调用 instanceof 运算符时返回 true 是非常有用的行为,因为 instanceof 运算符又一个额外的功能:它可以保证第一个操作数所引用的对象不是 null。如果 instanceof 告知一个引用变量是某个特定类型的实例,那么就可以将其转型为该类型,并调用该类型的方法,而不用担心会抛出 ClassCastException 或 NullPointerException 异常。

构造器的陷阱

构造器是 Java 每个类都会提供的一个“特殊方法”。构造器负责对 Java 对象执行初始化操作,不管是定义实例变量时指定的输出值。还是在非静态初始化块中所做的操作,实际都会被提取到构造器中被执行。

构造器之前的 void

关于构造器是否有返回值,很多资料都争论不休,笔者倾向于认为构造器是有返回值的,构造器返回它初始化的 Java 对象(用 new 调用构造器就可看到构造器的返回值)。也就是说,构造器的返回值类型总是当前类。

即使不讨论关于构造器是否有返回值的问题,有一点是可以确定的:构造器不能声明返回值类型,也不能使用 void 声明构造器没有返回值。

当为构造器声明添加任何返回值类型声明,或者添加 void 声明该构造器没有返回值时,编译器并不会提示这个构造器有错误,只是系统会把这个所谓的“构造器”当成普通方法处理。编译器会为类提供一个默认的构造器,这个默认的构造器无需参数,也不会执行任何自定义的初始化操作。

构造器创建对象吗

大部分 Java 书籍、资料都笼统地说:通过构造器来创建一个 Java 对象。这样很容易给人一个感觉,构造器负责创建 Java 对象。但实际上构造器并不会创建 Java 对象,构造器只是负责执行初始化,在构造器执行之前,Java 对象所需要的内存空间,应该说是由 new 关键字申请出来的。

绝大部分时,程序使用 new 关键字作为一个 Java 对象申请空间之后,都需要使用构造器为这个对象执行初始化。在某些时候,程序创建 Java 对象无需构造器,以下下面两个方式创建的 Java 对象无需构造器。

  • 使用反序列化的方式恢复 Java 对象;
  • 使用 clone 方法赋值 Java 对象。

上面两种方法都无需调用构造器对 Java 对象执行初始化。下面程序使用反序列化机制来恢复一个 Java 对象:

  1. class Wolf implements Serializable {
  2. private String name;
  3. public Wolf(String name) {
  4. System.out.println("调用有参数的构造器");
  5. this.name = name;
  6. }
  7. @Override
  8. public boolean equals(Object obj) {
  9. if (this == obj) {
  10. return true;
  11. }
  12. if (obj.getClass() == Wolf.class) {
  13. Wolf target = (Wolf)obj;
  14. return target.name.equals(this.name);
  15. }
  16. return false;
  17. }
  18. @Override
  19. public int hashCode() {
  20. return name.hashCode();
  21. }
  22. }
  23. public class SerializableTest {
  24. public static void main(String[] args) throws Exception {
  25. Wolf w = new Wolf("灰太狼");
  26. System.out.println("Wolf对象创建完成");
  27. Wolf w2 = null;
  28. ObjectOutputStream oos = null;
  29. ObjectInputStream ois = null;
  30. try {
  31. //创建对象输出流
  32. oos = new ObjectOutputStream(new FileOutputStream("a.bin"));
  33. //创建对象输入流
  34. ois = new ObjectInputStream(new FileInputStream("a.bin"));
  35. //序列化输出Java对象
  36. oos.writeObject(w);
  37. oos.flush();
  38. //反序列化恢复Java对象
  39. w2 = (Wolf)ois.readObject();
  40. //两个对象的实例变量值完全相等,下面输出true
  41. System.out.println(w.equals(w2));
  42. //两个对象不相同,下面输出false
  43. System.out.println(w == w2);
  44. } finally {
  45. if (oos != null) {
  46. oos.close();
  47. }
  48. if (ois != null) {
  49. ois.close();
  50. }
  51. }
  52. }
  53. }

上面程序中采用反序列化机制恢复得到了一个 Wolf 对象,程序恢复这个 Wolf 对象时无需调用它的构造器执行初始化。

正如上面程序中看到的,当创建 Wolf 对象时,程序调用了相应的构造器来对该对象执行初始化;当程序通过反序列化机制恢复 Java 对象时,系统无需再调用构造器来执行初始化。

通过反序列化恢复出来的 Wolf 对象当然和原来的 Wolf 对象具有完全相同的实例变量值,但系统中将会产生两个 Wolf 对象。

可能有读者对自己以前写的某些单列类感到害怕,以前那些通过构造器私有来保证只产生一个实例的类真的不会产生多个实例吗?如果程序使用反序列化机制不是可以获取多个实例吗?没错,程序完全通过这种反序列化机制确实会破坏单列类的规则。当然,大部分时候也不会主动使用反序列化去破坏单例类的规则,如果真的想保证反序列化时也不会产生多个 Java 实例,则应该为单例类他提供 readResolve() 方法,该方法保证反序列化时得到已有的 Java 实例。

如下单例类提供了 readResolve() 方法,因此即使通过反序列化机制来恢复 Java 实例,依然可以保证程序中只有一个 Java 实例。

  1. class Singleton implements Serializable {
  2. private static Singleton instance;
  3. private String name;
  4. private Singleton(String name) {
  5. System.out.println("调用有参数的构造器");
  6. this.name = name;
  7. }
  8. public static Singleton getInstance(String name) {
  9. //只有当instance为null时才创建该对象
  10. if (instance == null) {
  11. instance = new Singleton(name);
  12. }
  13. return instance;
  14. }
  15. //提供readResolve()方法
  16. private Object readResolve() throws ObjectStreamException {
  17. //得到已有的instance实例
  18. return instance;
  19. }
  20. }
  21. public class SingletonTest {
  22. public static void main(String[] args) throws Exception {
  23. //调用静态方法来获取Wolf实例
  24. Singleton s = Singleton.getInstance("灰太狼");
  25. System.out.println("Wolf对象创建完成");
  26. Singleton s2 = null;
  27. ObjectOutputStream oos = null;
  28. ObjectInputStream ois = null;
  29. try {
  30. //创建对象输出流
  31. oos = new ObjectOutputStream(new FileOutputStream("b.bin"));
  32. //创建对象输入流
  33. ois = new ObjectInputStream(new FileInputStream("b.bin"));
  34. //序列化输出Java对象
  35. oos.writeObject(s);
  36. oos.flush();
  37. //反序列化恢复Java对象
  38. s2 = (Singleton)ois.readObject();
  39. //两个对象相同,下面输出true
  40. System.out.println(s == s2);
  41. } finally {
  42. if (oos != null) {
  43. oos.close();
  44. }
  45. if (ois != null) {
  46. ois.close();
  47. }
  48. }
  49. }
  50. }

上面程序为 Singleton 类提供了 readResolve() 方法,当 JVM 反序列化地恢复一个新对象时,系统会自动调用这个 readResolve() 方法返回指定号的对象,从而保证系统通过反序列化机制不会产生多个 Java 对象。

运行上面程序,程序判断 s == s2 是否相同将输出 true,这表明反序列化机制恢复出来的 Java 对象依然是原有的 Java 对象。通过这种方式可保证反序列化时 Singleton 依然是单例类。

除了反序列化机制恢复 Java 对象无需构造器之外,使用 clone() 方法赋值 Java 对象也无需调用构造器。如果希望某个 Java 类的实例是可复制的,对该 Java 类有如下两个要求:

  • 让该 Java 类实现 Cloneable 接口;
  • 为该 Java 类提供 clone() 方法,该方法负责进行复制。

下面程序中的 Dog 实例就可直接调用 clone() 方法来赋值自己。

  1. class Dog implements Cloneable {
  2. private String name;
  3. private double weight;
  4. public Dog(String name, double weight) {
  5. System.out.println("调用有参数的构造器");
  6. this.name = name;
  7. this.weight = weight;
  8. }
  9. //重写Object类的clone()方法
  10. @Override
  11. protected Object clone() {
  12. Dog dog = null;
  13. try {
  14. //调用Object类的clone方法完成复制
  15. dog = (Dog) super.clone();
  16. } catch (CloneNotSupportedException e) {
  17. e.printStackTrace();
  18. }
  19. return dog;
  20. }
  21. @Override
  22. public int hashCode() {
  23. return name.hashCode() * 17 + (int)weight;
  24. }
  25. @Override
  26. public boolean equals(Object obj) {
  27. if (this == obj) {
  28. return true;
  29. }
  30. if (obj.getClass() == Dog.class) {
  31. Dog target = (Dog)obj;
  32. return target.name.equals(this.name) && target.weight == this.weight;
  33. }
  34. return false;
  35. }
  36. }
  37. public class CloneTest {
  38. public static void main(String[] args) {
  39. Dog dog = new Dog("Blot", 9.8);
  40. System.out.println("Dog对象创建完成");
  41. //采用clone()方法复制一个新的Java对象
  42. Dog dog2 = (Dog)dog.clone();
  43. //两个对象的实例变量值完全相同,下面输出true
  44. System.out.println(dog.equals(dog2));
  45. //两个对象不相同,下面输出false
  46. System.out.println(dog == dog2);
  47. }
  48. }

上面程序中代码采用 clone() 方法复制了一个 Dog 对象,复制这个 Dog 对象时无需调用它的构造器执行初始化。

正如上面程序中看到的,当创建 Dog 对象时,程序调用了相应的构造器来对该对象执行初始化,构造器被调用了一次;当程序通过 clone 方法复制 Java 对象时,系统无需再调用构造器来执行初始化。

通过 clone() 方法复制出来的 Dog 对象当然和原来的 Dog 对象具有完全相同的实例变量值,但系统中将产生两个 Dog 对象,因此程序判断 dog == dog2 时将输出 false。

无限递归的构造器

在一些情况下,程序可能导致构造器进行无限递归。示例如下:

  1. public class ConstructorRecursion {
  2. ConstructorRecursion rc;
  3. {
  4. rc = new ConstructorRecursion();
  5. }
  6. public ConstructorRecursion() {
  7. System.out.println("执行无参数的构造器");
  8. }
  9. public static void main(String[] args) {
  10. ConstructorRecursion rc = new ConstructorRecursion();
  11. }
  12. }

表面上看,这个程序没有任何问题,ConstructorRecursion 类的构造器中没有任何代码,它的构造器中只有一行简单的输出代码。但不要忘记了,不管是定义实例变量时指定的初始值,还是在非静态初始化块中执行的初始化操作,最终都将提取到构造器中执行。如果用 javap 工具来分析上面 ConstructorRecursion 类,将看到如下所示的结果:

  1. Compiled from "ConstructorRecursion.java"
  2. public class ConstructorRecursion {
  3. ConstructorRecursion rc;
  4. public ConstructorRecursion();
  5. Code:
  6. 0: aload_0
  7. 1: invokespecial #1 // Method java/lang/Object."<init>":()V
  8. 4: aload_0
  9. 5: new #2 // class ConstructorRecursion //递归调用的构造器
  10. 8: dup
  11. 9: invokespecial #3 // Method "<init>":()V
  12. 12: putfield #4 // Field rc:LConstructorRecursion;
  13. 15: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
  14. 18: ldc #6 // String 执行无参数的构造器
  15. 20: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
  16. 23: return
  17. public static void main(java.lang.String[]);
  18. Code:
  19. 0: new #2 // class ConstructorRecursion
  20. 3: dup
  21. 4: invokespecial #3 // Method "<init>":()V
  22. 7: astore_1
  23. 8: return
  24. }

因为上面代码调用了 ConstructorRecursion 类构造器,所以实际运行该程序将导致出现 java.lang.StackOverflowError 异常。

这个程序给出的教训是:无论如何不要导致构造器产生递归调用。也就是说,应该:

  • 尽量不要在定义实例变量时指定实例变量的值为当前类的实例;
  • 尽量不要在初始化块中创建当前类的实例;
  • 尽量不要在构造器内调用本构造器创建 Java 对象。

持有当前类的实例

从上一节的程序可以看出,当构造器递归调用当前构造器时,程序运行时将会引发 StackOverflowError 异常。前面程序已经指出,定义实例变量时指定实例变量的值为当前类的实例很容易导致构造器递归调用。

也就是说,当某个类的对象持有当前类的实例时,某个实例递归地引用当前类的实例很容量导致构造器递归调用。不过在一些特定的情况下,程序必须让某个类的一个实例持有当前类的另一个实例,例如链表,每个节点都持有一个引用,该引用指向下一个链表节点。

对于一个 Java 类而言,它的一个实例持有当前类的另一个实例的引用是被允许的,只要程序初始化它所持有当前类的实例时不会引起构造器递归。

  1. public class InstanceTest {
  2. private String name;
  3. //持有当前类的实例
  4. private InstanceTest instance;
  5. //定义一个无参数的构造器
  6. public InstanceTest() {
  7. }
  8. //定义一个有参数的构造器
  9. public InstanceTest(String name) {
  10. instance = new InstanceTest();
  11. instance.name = name;
  12. }
  13. //重写toString()方法
  14. @Override
  15. public String toString() {
  16. return "InstanceTest[instance=" + instance + "]";
  17. }
  18. public static void main(String[] args) {
  19. InstanceTest in = new InstanceTest();
  20. InstanceTest in2 = new InstanceTest("测试name");
  21. System.out.println(in);
  22. System.out.println(in2);
  23. }
  24. }

上面程序中定义了一个 InstanceTest 类。该类的实例持有另一个 InstanceTest 对象,程序只要不再 InstanceTest 构造器的初始化代码块里形成递归调用,这个类就是安全的。上面程序创建了 2 个 Instance 对象,一个持有的 instance 属性为 null,另一个持有的 instance 是有效的。

虽然上面程序是安全的,但一个类的实例持有当前类的另一个实例总是有风险的,即使避免了构造器的递归调用,上面程序的 toString() 方法也是有危险的。把程序中 in 和 in2 两个对象设置为相互引用,也就是将 main 方法改为如下形式:

  1. public static void main(String[] args) {
  2. InstanceTest in = new InstanceTest();
  3. InstanceTest in2 = new InstanceTest("测试name");
  4. //让两个对象相互引用
  5. in.instance = in2;
  6. in2.instance = in;
  7. System.out.println(in);
  8. System.out.println(in2);
  9. }

当 in2 和 in 两个对象形成嵌套引用后,程序为 InstanceTest 提供的 toString() 方法就会产生无穷递归了。再次运行程序,将看到 java.lang.StackOverflowError 错误。

总之,如果一个类的实例持有当前类的其它实例时需要特别小心,因为程序很容易形成递归调用。

到底调用哪个重载的方法

  1. public class OverrideTest {
  2. public void info(String name, double count) {
  3. System.out.println("name参数为:" + name);
  4. System.out.println("count参数为:" + count);
  5. }
  6. public static void main(String[] args) {
  7. OverrideTest ot = new OverrideTest();
  8. //试图调用ot的info方法
  9. ot.info("crazyit.org", 5);
  10. }
  11. }

在上面的程序中,程序试图调用 OverrideTest 对象的 info() 方法,但传入的参数与该对象中的 info 方法所需的参数并不匹配。在这种情况下,程序会出现怎样的情况呢?

编译、运行上面的程序,发现一切正常。很明显是程序将实参 5 自动转换为 5.0,以使之匹配 info(String, double) 方法的要求。

通过上面介绍可以发现,Java 虚拟机在识别方法时具有一定的“智能”,它可以对调用方法的实参进行向上转型,使之适合被调用方法的需要。

调用方法时传入的实际参数可能被向上转型,通过这种向上转型可以使之符合被调用方法的实际需要。

  1. public class OverrideTest2 {
  2. public void info(String name, double count) {
  3. System.out.println("name参数为:" + name);
  4. System.out.println("count参数为:" + count);
  5. }
  6. public void info(String name, int count) {
  7. System.out.println("name参数为:" + name);
  8. System.out.println("整型的count参数为:" + count);
  9. }
  10. public static void main(String[] args) {
  11. OverrideTest2 ot = new OverrideTest2();
  12. //试图调用ot的info方法
  13. ot.info("crazyit.org", 5);
  14. }
  15. }

上面的程序调用了 OverrideTest2 对象的 info() 方法。根据前面介绍不难发现,此时的调用既可以匹配 info(String, double) 方法,也可以匹配 info(String, int) 方法,虚拟机到底调用哪个方法呢?

通过编译、运行上面的程序可以得知,虚拟机调用的是 info(String, int) 方法,这是为什么呢?

很明显,虚拟机比我们想象的更加聪明,Java 的重载解析过程分成如下两个阶段:

  • 第一阶段 JVM 将会选取所有可获得并匹配调用的方法或构造器,在这个节点里,info(String, double) 方法和 info(String, int) 方法都会被选取出来;
  • 第二阶段决定到底要调用哪个方法,此时 JVM 会在第一阶段所选取的方法货构造器中再次选取最精确匹配的那一个。对于上面程序来说,ot.info("crazyit.org", 5); 很明显更匹配 info(String, int) 方法,也不是匹配 info(String, double) 方法。

掌握了上面的理论之后,再来判断一下下面程序到底调用了哪个方法:

  1. public class OverrideTest3 {
  2. public void info(Object obj, double count) {
  3. System.out.println("obj参数为:" + obj);
  4. System.out.println("count参数为:" + count);
  5. }
  6. public void info(Object[] objs, double count) {
  7. System.out.println("objs参数为:" + objs);
  8. System.out.println("count参数为:" + count);
  9. }
  10. public static void main(String[] args) {
  11. OverrideTest3 ot = new OverrideTest3();
  12. //试图调用ot的info方法
  13. ot.info(null, 5);
  14. }
  15. }

上面的程序调用了 OverrideTest3 对象的 info() 方法。但此处调用时传入的第一个参数是 null,它既可以匹配第一个 info(Object, double) 方法,也可以匹配第二个 info(Object[], double) 方法。问题是,此时匹配哪个方法呢?

根据精确匹配原则,当实际调用时传入的实参同时满足多个方法时,如果某个方法的形参要求参数范围越小,那这个方法就越精确。很明显 Object[] 可看成 Object 的子类,info(Object[], double) 方法匹配得更精确。

  1. public class OverrideTest4 {
  2. public void info(Object obj, int count) {
  3. System.out.println("obj参数为:" + obj);
  4. System.out.println("count参数为:" + count);
  5. }
  6. public void info(Object[] objs, double count) {
  7. System.out.println("objs参数为:" + objs);
  8. System.out.println("count参数为:" + count);
  9. }
  10. public static void main(String[] args) {
  11. OverrideTest4 ot = new OverrideTest4();
  12. //试图调用ot的info方法
  13. ot.info(null, 5);
  14. }
  15. }

上面程序同样试图调用 OverrideTest4 对象的 info 方法,但此时的情况更复杂。程序调用 info() 方法的第一个参数是 null,它与 info(Object[], double) 方法匹配的更精确,但调用 info() 方法的第二个参数是 5,它与 info(Object, int) 方法匹配的更精确。到底选择哪个?

尝试编译、运行上面的程序会提示:

  1. Error:(19, 11) java: info的引用不明确
  2. OverrideTest4 中的方法 info(java.lang.Object,int) OverrideTest4 中的方法 info(java.lang.Object[],double) 都匹配

从上面运行结果可以看出,在这种复杂的情况下,JVM 无法判断哪个方法更匹配实际调用,程序将会导致编译错误。

方法重写的陷阱

Java 方法重写也是很常见的现象,当子类需要改变从父类继承得到的方法的行为时,子类就可以重写父类的方法。

重写 private 方法

对于使用 private 修饰符修饰的方法,只能在当前类中访问该方法,子类无法访问父类中定义的 private 方法。既然子类无法访问父类的 private 方法,当然也就无法重写该方法。

如果子类中定义了一个与父类 private 方法具有相同方法名、相同形参列表、相同返回值类型的方法,依然不是重写、只是子类中重新定义了一个新方法。例如:下面程序是完全正确的:

  1. class Base {
  2. //test方法是private访问权限,子类不可访问该方法
  3. private void test() {
  4. System.out.println("父类的test方法");
  5. }
  6. }
  7. public class Sub extends Base {
  8. //此处并不是方法重写
  9. public void test() {
  10. System.out.println("子类定义的test方法");
  11. }
  12. }

在子类中的 test() 方法和父类的 test() 方法有相同方法名、相同的形参列表,但并不是重写,只是子类重新定义了一个新方法而已。为了证明 Sub 类中的 test() 方法没有重写父类的方法,可以使用 @Override 来修饰 Sub 类中的 test() 方法。再次尝试编译该程序,将会看到“方法不会覆盖或实现超类的方法”的错误提示。

由此可见,父类中定义的 private 方法不可能被子类重写。

重写其它访问权限的方法

上面介绍了父类中定义的 private 方法不可能被子类重写,其关键原因就是子类不可能访问父类的 private 方法。还有一种情况,如果父类中定义了使用默认访问修饰符(也就是不适用访问控制符)的方法,这个方法同样可能无法被重写。

对于不适用访问控制符修饰的方法,它只能被与当前类处于一个包的其他类访问,其它包中的子类依然无法访问该方法。下面程序在父类中定义了一个 run() 方法,该方法不适用任何访问修饰符修饰,表示它是包访问控制权限。这意味着,只有与当前类处于同一个包中的其它类才能访问该方法。

  1. package com.yeskery.object;
  2. public class Animal {
  3. void run() {
  4. System.out.println("Animal的run方法");
  5. }
  6. }

下面定义一个 Wolf 类,该类继承 Animal 类且不与 Animal 不在同一个包内。

  1. package com.yeskery.object.wolf;
  2. public class Wolf extends com.yeskery.object.Animal {
  3. //重新定义一个run方法,并非重写父类的方法
  4. private void run() {
  5. System.out.println("Wolf的run方法");
  6. }
  7. }

在上面的程序中,Wolf 继承了 Animal 类,但因为 Wolf 类和 Animal 类不是位于同一个包内,所以 Wolf 类不能访问 Animal 类中定义的 run() 方法。由此不难发现,虽然 Wolf 类中定义了一个访问权限更小的(private)的 run() 方法,但这只是 Wolf 类重新定义了一个新的 run() 方法,与父类 Animal 的 run() 方法无关。

非静态内部类的陷阱

内部类也是 Java 提供的一个常用语法。内部类能提供更好的封装,而且它可以直接访问外部类的 private 成员,因此在一些特殊的场合下更常用。但使用内部类时也有一个容易出错的陷阱。

非静态内部类的构造器

下面程序定义了一个非静态内部类,还为该非静态内部类创建了实例。

  1. public class Outer {
  2. public static void main(String[] args) throws Exception {
  3. new Outer().test();
  4. }
  5. private void test() throws Exception {
  6. //创建非静态内部类的对象
  7. System.out.println(new Inner());
  8. //使用反射的方式来创建Inner对象
  9. System.out.println(Inner.class.newInstance());
  10. }
  11. //定义一个非静态内部类
  12. public class Inner {
  13. @Override
  14. public String toString() {
  15. return "Inner对象";
  16. }
  17. }
  18. }

上面程序在 Outer 类中定义了一个 Inner 类,通过两种方式来创建 Inner 实例,new Inner() 代码直接通过 new 调用 Inner 无参数构造器来创建实例,Inner.class.newInstance() 代码则通过反射来调用 Inner 无参数构造器来创建实例。

尝试编译、运行上面的程序,发现程序抛出如下运行时异常:

  1. Inner对象
  2. Exception in thread "main" java.lang.InstantiationException: Outer$Inner
  3. at java.lang.Class.newInstance(Class.java:427)
  4. at Outer.test(Outer.java:15)
  5. at main(Outer.java:9)
  6. Caused by: java.lang.NoSuchMethodException: Outer$Inner.<init>()
  7. at java.lang.Class.getConstructor0(Class.java:3082)
  8. at java.lang.Class.newInstance(Class.java:412)
  9. ... 2 more

通过上面运行结果可以看出,程序通过 new 创建 Inner 对象完全正常,而通过反射来创建 Inner 对象时则抛出了异常。这是什么原因呢?

下面通过 Javap 工具来分析这个 Inner 类:
javap -c Outer.Inner
可以看到如下的输出结果:

  1. Compiled from "Outer.java"
  2. public class Outer$Inner {
  3. final Outer this$0;
  4. public Outer$Inner(Outer); //Inner类的构造器
  5. Code:
  6. 0: aload_0
  7. 1: aload_1
  8. 2: putfield #1 // Field this$0:LOuter;
  9. 5: aload_0
  10. 6: invokespecial #2 // Method java/lang/Object."<init>":()V
  11. 9: return
  12. public java.lang.String toString();
  13. Code:
  14. 0: ldc #3 // String Inner对象
  15. 2: areturn
  16. }

从上面的结果可以看出,费静态内部类 Inner 并没有无参数的构造器,它的构造器需要一个 Outer 参数。这符合了非静态内部类的规则:非静态内部类必须寄生在外部类的实例中,没有外部类的对象,就不可能产生非静态内部类的对象。因此,非静态内部类不可能有无参数的构造器————即使系统为非静态内部类提供一个默认的构造器,这个默认的构造器也需要一个外部类形参。

对于上面程序中通过 new 来创建 Inner 实例对象,程序表面上调用 Inner 无参数的构造器创建实例,实际上虚拟机底层会将 this(代表当前默认的 Outer 对象)作为实参传入 Inner 构造器。至于上面程序中通过反射来创建 Inner 实例对象的效果则不同,程序通过反射指定调用 Inner 无参数的构造器所以引发了运行时异常。

为了证实非静态内部类的构造器总是需要一个 Outer 对象作为参数,可以为 Inner 类增加两个构造器,如下所示:

  1. public Inner() {
  2. System.out.println("Inner无参数的构造器");
  3. }
  4. public Inner(String name) {
  5. System.out.println("Inner的构造器:" + name);
  6. }

再次使用 javap 工具来分析这个 Inner 类,可以看到如下所示的输出结果:

  1. public class Outer$Inner {
  2. final Outer this$0;
  3. public Outer$Inner(Outer); //原本无参数的构造器
  4. Code:
  5. 0: aload_0
  6. 1: aload_1
  7. 2: putfield #1 // Field this$0:LOuter;
  8. 5: aload_0
  9. 6: invokespecial #2 // Method java/lang/Object."<init>":()V
  10. 9: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
  11. 12: ldc #4 // String Inner无参数的构造器
  12. 14: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
  13. 17: return
  14. public Outer$Inner(Outer, java.lang.String); //原本有一个字符串参数的构造器
  15. Code:
  16. 0: aload_0
  17. 1: aload_1
  18. 2: putfield #1 // Field this$0:LOuter;
  19. 5: aload_0
  20. 6: invokespecial #2 // Method java/lang/Object."<init>":()V
  21. 9: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
  22. 12: new #6 // class java/lang/StringBuilder
  23. 15: dup
  24. 16: invokespecial #7 // Method java/lang/StringBuilder."<init>":()V
  25. 19: ldc #8 // String Inner的构造器:
  26. 21: invokevirtual #9 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  27. 24: aload_2
  28. 25: invokevirtual #9 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  29. 28: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  30. 31: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
  31. 34: return
  32. public java.lang.String toString();
  33. Code:
  34. 0: ldc #11 // String Inner对象
  35. 2: areturn
  36. }

从上面的结果可以看出,如果为非静态内部类定义一个无参数的构造器,编译器将为之生成对应的需要外部类参数的构造器;为非静态内部类定义一个带 String 参数的构造器,编译器将为之生成对应的构造器增加了一个 Outer 参数。由此可以,系统在编译阶段总会为非静态内部类的构造器增加一个参数,非静态内部类的构造器的第一个形参类型总是外部类。

系统在编译阶段总会为非静态内部类的构造器增加一个参数,非静态内部类的构造器的第一个形参总是外部类。因此调用非静态内部类的构造器时必须传入一个外部类对象作为参数,否则程序将会引发运行时异常。

非静态内部类不能有用静态变量

对于非静态内部类而言,由于它本身就是一个非静态的上下文环境,因此非静态内部类不允许拥有静态变量。

如果尝试在内部类中定义静态变量,将会在编译的时候提示“内部类不能拥有静态声明”的错误。

非静态内部类的子类

由于非静态内部类没有无参数的构造器,因此通过非静态内部类派生子类时也可能存在一些陷阱。示例如下:

  1. class Out {
  2. class In {
  3. public void test() {
  4. System.out.println("In的teset方法");
  5. }
  6. }
  7. //定义类A继承In类
  8. class A extends In {
  9. }
  10. }
  11. public class OutTest extends Out.In{
  12. public static void main(String[] args) {
  13. System.out.println("Hello world!");
  14. }
  15. }

上面程序在 Out 类之内定义了一个非静态内部类 In,接着从这个 In 类派生了两个子类:A 和 OutTest。其中,派生类 A 也是作为 Out 的内部类,而类 OutTest 则与 Out 没有任何关系。尝试编译上面的程序,将看到“需要包含 Out.In 的封闭实例”的错误。

上面程序错误的关键在于,由于非静态内部类 In 必须寄生于 Out 对象之内,因此父类 Out.In 根本没有无参数的构造器。而程序定义其子类 Out.In 时,也没有定义构造性傲气,那么系统会为它提供一个无参数的构造器。在 OutTest 无参数的构造器内,编译器会增加代码 super()————子类总会调用父类的构造器。对于这个 super() 的调用,指定调用父类 Out.In 无参数构造器,必然导致编译错误。为了解决这个问题,应该位 OutTest 显式定义一个构造器,在该构造器中显式地调用 Out.In 父类对应的构造器。也就是将 OutTest 类的代码改为如下形式:

  1. public class OutTest extends Out.In{
  2. public OutTest() {
  3. //因为Out.In没有无参数的构造器
  4. //显式调用父类指定的构造器
  5. new Out().super();
  6. }
  7. //...
  8. }

从上面程序可以看出,程序为 OutTest 定义的构造器通过显式的方式调用了父类的构造器,在调用父类构造器时,使用 new Out() 作为主调————即以一个 Out 对象作为主调,其实这个主调会作为参数传入 super(),也就是传给 In 类的带一个 Out 参数的构造器。

但对于 In 的另一个子类而言,由于它本身就是 Out 的内部类,因此系统在编译它时也会为它的构造器增加一个 Out 形参,这个 Out 形参就可以解决调用 In 类的构造器的问题。也就是说,对于类 A 而言,编译器会为之增加如下构造器:

  1. A(Out this) {
  2. this.super();
  3. }

上面的代码增加的 this 参数是编译器自动增加的。当程序通过 Out 对象创建 A 实例时,该 Out 对象就会被传给这个 this 引用。

通过上面介绍似乎可以发现,非静态内部类在外部以内派生子类是安全的。不过这个结论也不是绝对正确的,请看如下程序:

  1. public class OuterBase {
  2. class InnerBase extends OuterBase {
  3. }
  4. class Inner extends InnerBase {
  5. }
  6. }

上面程序定义了一个 OuterBase 外部类,并在该外部类之内定义了一个 InnerBase 内部类,然后在 OuterBase 之内 以 InnerBase 为父类派生了 Inner 子类。这个程序看上去没有错误,但尝试编译该程序,看到了类似“无法在调用父类型构造器函数之前引用 this”的编译错误,但在部分新版本 JDK8 中,该程序可以正常编译通过,应该是新版本的编译器为 Inner 类增加构造器增加外部类 OuterBase 的引用

下面以不能通过编译的 JDK 来分析这个编译错误,对于 Inner 子类来说,系统编译时同样会提供下如下形式的构造器:

  1. Inner(OuterBase this) {
  2. this.super();
  3. }

上面的代码就是出错的关键:无法在调用父类的构造器之前引用 this。

要解决这个问题,可以为 Inner 显式提供一个构造器,显式提供的构造器为 this 增加 OuterBase,如下所示:

  1. public Inner() {
  2. OuterBase.this.super();
  3. }

总之,由于非静态内部类必须寄生在外部类的实例之中,程序创建非静态内部类对象的实例,派生非静态内部类的子类时都必须特别小心,否则很容易陷入陷阱。

如果条件允许,推荐多使用静态内部类,而不是非静态内部类。对于静态内部类来说,外部类相当于它的一个包,因此静态内部类的用法就简单多了,限制也少多了。

static 关键字

static 是一个常见的修饰符,它只能用于修饰在类里定义的成员:Field、方法、内部类、初始化块、内部枚举类。static 的作用就是把类里定义的成员变成静态成员,也就是所谓类成员。

静态方法属于类

被 static 关键字修饰的成员(Field、方法、内部类、初始化块、内部枚举类)属于类本身,而不是单个的 Java 对象。具体到静态方法也是如此,静态方法属于类,而不是属于 Java 对象。

首先看下面一个简单的程序。

  1. public class NullInvocation {
  2. public static void info() {
  3. System.out.println("静态的info方法");
  4. }
  5. public static void main(String[] args) {
  6. //声明一个NullInvocation变量,并将一个null赋给该变量
  7. NullInvocation ni = null;
  8. ni.info();
  9. }
  10. }

编译运行上面的程序,发现一切正常。这就是因为,info() 方法是静态方法,它并不是 NullInvocation 对象,而是属于 NullInvocation 类,虽然程序中使用了 ni 变量来调用这个 info() 方法,但实际底层依然是使用 NullInvocation 类作为主调来调用该方法,因此可以看到程序能正常输出。

  1. class Animal {
  2. public static void info() {
  3. System.out.println("Animal的Info方法");
  4. }
  5. }
  6. public class Wolf extends Animal {
  7. public static void info() {
  8. System.out.println("Wolf的Info方法");
  9. }
  10. public static void main(String[] args) {
  11. //定义一个Animal变量,引用一个Animal实例
  12. Animal a1 = new Animal();
  13. a1.info();
  14. //定义第二个Animal变量,引用到一个Wolf实例
  15. Animal a2 = new Wolf();
  16. a2.info();
  17. }
  18. }

上面程序中先定义了一个 Animal 类,其中包含了一个 info() 方法,接着从 Animal 派生了一个 Wolf 子类,Wolf 子类重写了父类的 info() 方法。

上面程序中定义了 2 个 Animal 变量 a1 和 a2,分别将 Animal 对象和 Wolf 对象赋给 a1 和 a2。如果按方法重写的规则来说,当 a1 调用 info 方法时,当然表现出 Animal 里 info() 方法的行为;当 a2 调用 info 方法时,应该表现出 Wolf 里 info() 方法的行为。

但不要忘记,上面 info() 方法是静态方法,静态方法属于类,而不是属于对象。因此当程序通过 a1、a2 来调用 info() 方法时,实际上都会委托声明 a1、a2 的类来执行。也就是说,不管是通过 a1 调用 info() 方法,还是通过 a2 调用 info() 方法,实际上都是通过 Animal 类来调用,因此两次调用 info 方法的结果完全相同。运行上面的程序将会看到:

  1. AnimalInfo方法
  2. AnimalInfo方法

静态内部类的限制

前面介绍内部类时已经指出,当程序需要使用内部类时,应尽量考虑使用静态内部类,而不是非静态内部类。当程序使用静态内部类时,外部类相当于静态内部类的一个包,因此使用起来比较方便;但另一方面,这也给静态内部类增加了一个限制————静态内部类不能访问外部类的非静态成员。

  1. class Outer {
  2. private String name;
  3. private static int staticField = 20;
  4. public static class Inner {
  5. public void info() {
  6. //分别访问外部类中静态的field和非静态的field
  7. System.out.println("外部类的staticField为:" + staticField);
  8. System.out.println("外部类的name为:" + name);
  9. }
  10. }
  11. }
  12. public class InnerTest {
  13. public static void main(String[] args) {
  14. //声明并创建Inner内部类的实例
  15. Outer.Inner in = new Outer.Inner();
  16. in.info();
  17. }
  18. }

静态内部的 info() 方法在 System.out.println("外部类的staticField为:" + staticField); 代码处访问了其外部类的静态 Field————staticField,这没有任何问题;但 info() 方法在 System.out.println("外部类的name为:" + name); 代码处访问其外部类的非静态 Field————name 时,这将引起编译错误:无法从静态上下文中引用费静态变量 name。

native 方法的陷阱

在 Java 方法定义中有一类特殊的方法:native 方法。对于 native 方法而言,Java 程序不会为该类提供实现体。示例如下:

  1. public class NativeTest {
  2. public native void info();
  3. }

上面的程序在 NativeTest 类中定义了一个 native 方法。这个方法就像一个“抽象方法”,只有方法签名,没有方法体,这就是 native 方法。从这个意义上来看,native 关键字和 abstract 关键字有点像。

不过,native 方法通常需要借助 C 语言来完成,即需要使用 C 语言为 Java 方法提供实现。其实现步骤如下:

  1. 用 javah 编译第一步生成的 class 文件,将产生一个 .h 文件;
  2. 写一个 .cpp 文件实现 native 方法,其中需要包含第(1)步产生的 .h 文件(.h 文件中又包含了 JDK 带的 jni.h 文件);
  3. 将第(2)步的 .cpp 文件编译成动态链接库文件;
  4. 在 Java 中用 System 的 loadLibrary() 方法或 Runtime 的 loadLibrary() 方法加载第(3)步产生的动态链接库文件,就可以在 Java 程序中调用这个 native() 方法了。

这里就产生了一个问题:在第(3)步编译 .cpp 文件时,将会使得该程序依赖于当前编译平台。也就说,native 方法做不到跨平台,它在不同平台上可能表现出不同的行为。为了说明这一点,请看如下程序:

  1. public class SleepTest {
  2. public static void main(String[] args) throws Exception {
  3. long start = System.currentTimeMillis();
  4. //让当前程序暂停2ms
  5. Thread.sleep(2);
  6. System.out.println(System.currentTimeMillis() - start);
  7. }
  8. }

上面程序非常简单,程序运行开始获取系统运行时间 start,接着暂停 2 毫秒,然后拿结束时的时间减去 start,输出两个时间的差值。应该输出多少?

尝试编译并多次运行上面的程序,可以发现程序输出的并不一定是 2。

这个运行结果看上去十分奇怪,仔细查看 JDK 关于 Threa.sleep() 方法的介绍,发现如下一段文字:“Causes the currently executing thead to sleep (temporarily cease execution) for the specified number of milliseconds, subject to the precision and accuracy of system timers and schedulers.”,这段英文意思说:让当前执行的线程 sleep(暂停)指定毫秒数,具体暂定多少毫米则取决于系统计时器的精度。也就说,Thread 的 sleep() 方法其实是一个 native 方法,这个方法的具体实现需要依赖于它所在的平台。

这个程序给出的教训是:千万不要过度相信 JDK 所提供的方法。虽然 Java 语言本身是跨平台的,但 Java 的 native 方法还是要依赖于具体的平台,尤其是 JDK 所提供的方法,更是包含了大量的 native 方法。使用这些方法时,要注意它们在不同平台可能存在的差异。

本文转载自:《疯狂Java 突破程序员基本功的16课》第七章 面向对象的陷阱

评论

发表评论 点击刷新验证码

提示

该功能暂未开放