Java中的重载和覆盖的细微差别 - rajivprab


我已经用Java编程超过五年了,并且认为我知道重载和覆盖是如何工作的。只有一次我开始思考并写下以下的角落案例,我才意识到我几乎不知道它。为了游戏化这些细微差别,我在下面将它们列为一系列谜题。

单一分发
假设有以下类:

class Parent {
  void print(String a) { log.info("Parent - String"); }
  void print(Object a) { log.info("Parent - Object"); }
}
 
class Child extends Parent {
  void print(String a) { log.info("Child - String"); }
  void print(Object a) { log.info("Child - Object"); }
}

将在下面打印什么?

String string = "";
Object stringObject = string;
 
// What gets printed?
Child child = new Child();
child.print(string);
child.print(stringObject);
 
Parent parent = new Child();
parent.print(string);
parent.print(stringObject);

答案是:

child.print(string);        // Prints: "Child - String"
child.print(stringObject);  // Prints: "Child - Object"
 
parent.print(string);       // Prints: "Child - String"
parent.print(stringObject); // Prints: "Child - Object"

child.print(string)和parent.print(string)是Java中面向对象编程的教科书示例。被调用的方法取决于“实际”实例类型,而不是“声明的”实例类型。即,无论您将变量定义为一个Child还是Parent,因为实际的实例类型是Child,Child::print都将被调用。
第二套打印更加棘手。stringObject和string都是完全相同的字符串。唯一的区别是string声明为一个String类型,而stringObject声明为一个Object类型。Java不支持双分发调度,因此,在处理方法参数时,重要的是参数的“声明”类型,而不是其“实际”类型。print(Object)将被调用,即使“实际”参数类型是String。


隐藏覆盖

class Parent {
  void print(Object a) { log.info("Parent - Object"); }
}
 
class Child extends Parent {
  void print(String a) { log.info("Child - String"); }
}

String string = "";
Parent parent = new Child();
parent.print(string);

结果:

parent.print(string);  // Prints: "Parent - Object"


Java在检查子类覆盖之前,首先会选择要调用的方法。在这种情况下,声明的实例类型是Parent,唯一匹配的方法是Parent::print(Object)。当Java检查任何潜在的覆盖Parent::print(Object)的方法时,它没有找到任何覆盖方法,因此这只能是它的执行的方法。

暴露覆盖

class Parent {
  void print(Object a) { log.info("Parent - Object!"); }
  void print(String a) { throw new RuntimeException(); }
}
 
class Child extends Parent {
  void print(String a) { log.info("Child - String!"); }
}

String string = "";
Parent parent = new Child();
parent.print(string);

答案:

parent.print(string);  // Prints: "Child - String!"

这和前面的例子之间的唯一区别是我们添加了一个新Parent::print(String)方法。这个方法实际上永远不会被执行 - 如果它被运行它将抛出异常!
java运行时找到了匹配的Parent::print(String)方法,然后看到此方法被覆盖Child::print(String)。


模糊参数

class Foo {
  void print(Cloneable a) { log.info("I am cloneable!"); }
  void print(Map a) { log.info("I am Map!"); }
}

HashMap cloneableMap = new HashMap();
Cloneable cloneable = cloneableMap;
Map map = cloneableMap;
 
// What gets printed?
Foo foo = new Foo();
foo.print(map);
foo.print(cloneable);
foo.print(cloneableMap);

答案:

foo.print(map);           // Prints: "I am Map!"
foo.print(cloneable);     // Prints: "I am cloneable!"
foo.print(cloneableMap);  // Does not compile

与第一个单分发single_dispatch示例类似,此处重要的是参数的声明类型,而不是实际类型。此外,如果有多个方法对给定参数同样有效,则Java会抛出编译错误并强制您指定应调用哪个方法。

多重继承 - 接口

interface Father {
  default void print() { log.info("I am Father!"); }
}
 
interface Mother {
  default void print() { log.info("I am Mother!"); }
}
 
class Child implements Father, Mother {}

new Child().print();


与前面的示例类似,这也不编译。具体来说,类定义本身Child将无法编译,因为在Father和中存在冲突的默认方法Mother。您需要更新Child类以指定其行为Child::print。请参阅此处以获取更详细的说明。

多重继承 - 类和接口

class ParentClass {
  void print() { log.info("I am a class!"); }
}
 
interface ParentInterface {
  default void print() { log.info("I am an interface!"); }
}
 
class Child extends ParentClass implements ParentInterface {}

new Child().print();  // Prints: "I am a class!"

说明: 上一节中的链接文章实际上也涵盖了这一点。如果类和接口之间存在继承冲突,则类获胜。

传递覆盖

class Parent {
  void print() { foo(); }
  void foo() { log.info("I am Parent!"); }
}
 
class Child extends Parent {
  void foo() { log.info("I am Child!"); }
}

new Child().print();  // Prints: "I am Child!"

覆盖方法即使对传递调用也会生效。有人可能会认为Parent::print总会调用Parent::foo。但是如果方法被覆盖,那么Parent::print将调用被覆盖的版本foo()。


私有覆盖

class Parent {
  void print() { foo(); }
  private void foo() { log.info("I am Parent!"); }
}
 
class Child extends Parent {
  void foo() { log.info("I am Child!"); }
}

new Child().print();  // Prints: "I am Parent!"

除了与前一个一个区别外,其余相同。Parent.foo()现在被宣布为私有。

通常假设将方法从公共更改为私有,只要编译仍然成功,就是纯粹的重构更改。上面的例子表明这是错误的 - 即使编译成功,系统行为也会以戏剧性的方式发生变化。
通过@Override所有覆盖方法使用注释将有助于防止此类回归,一旦任何基本方法的可见性发生更改,就会产生编译错误。


静态覆盖

class Parent {
  static void print() { log.info("I am Parent!"); }
}
 
class Child extends Parent {
  static void print() { log.info("I am Child!"); }
}

Child child = new Child();
Parent parent = child;
 
parent.print();
child.print();

parent.print(); // Prints: "I am Parent!"
child.print();  // Prints: "I am Child!"

Java不允许重写静态方法。如果在父类和子类中都定义了相同的静态方法,则实例的实际类型根本不重要。只有声明的类型用于确定调用两个方法中的哪一个。

这与非静态方法的情况完全相反,其中忽略声明的类型以支持实际类型。因此,为什么在将方法从非静态更改为静态或反之亦然时需要小心。即使没有编译错误,系统行为也可能发生巨大变化。
这是使用@Override注释标记所有覆盖方法的另一个原因。在上面的例子中,添加注释时会出现编译错误Child::print,告诉您由于它是静态的,因此无法覆盖该方法。
这也是为什么永远不要使用类的实例调用静态方法的好习惯 - 它可能导致像上面这样令人惊讶的行为,并且在进行有问题的重构更改时无法提醒您。许多像Intellij这样的IDE会在从非静态上下文中调用静态方法时发出警告,最好跟进这些警告。

静态链接

class Parent {
  void print() { staticMethod(); instanceMethod(); }
  static void staticMethod() { log.info("Parent::staticMethod"); }
  void instanceMethod() { log.info("Parent::instanceMethod"); }
}
 
class Child extends Parent {
  static void staticMethod() { log.info("Child::staticMethod"); }
  void instanceMethod() { log.info("Child::instanceMethod"); }
}

Child child = new Child();
child.print();

结果:

Parent::staticMethod
Child::instanceMethod

这是我们之前介绍过的一些不同概念的组合。对于实例方法,即使调用者在父级中,覆盖也会生效。但是,对于静态方法,即使变量的声明类型是Child,Parent::staticMethod也会因为中间foo()方法而被调用。

总结
如果碰到其中一个,那就是非常棘手,继承容易出错。如果你想要聪明,有一天它会咬你的屁股。使用非常愚蠢的护栏和最佳实践来保护自己:

  1. 始终使用@Override注释标记所有覆盖方法
  2. 始终使用类引用而不是实例引用来调用静态方法
  3. 设置IDE警报或lint错误以强制执行上述和其他代码异味
  4. 使用组合而不是继承

1