Java中的過載和覆蓋的細微差別 - rajivprab

banq發表於2019-08-17

我已經用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. 使用組合而不是繼承

相關文章