Java 中的封裝、繼承與多態

在前面的Java — 面向對象的編程語言》裏,介紹了面向對象的三大特徵:封裝、繼承、多態,主要是概念上的講解,本篇文章將從代碼出發,看看 Java 中的封裝、繼承與多態。

一、封裝

在編程時,把數據(屬性)和有關屬性的一些操作(方法)綁定在一起,形成一個不可分開的集合(類),這個過程就是封裝(Encapsulation)。

封裝時,我們需要隱藏對象的屬性和實現細節,僅對外公開接口,並控制在程序中屬性的讀和寫的訪問級別

一般情況下,我們會把所有的屬性都私有化,對每個屬性提供 getter (讀) 和 setter(寫) 方法,供外界使用:

public class Person {
  private String name;
  private int age;

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }

  public int getAge() {
    return age;
  }

  public void setAge(int age) {
    this.age = age;
  }
}

public class Me {
  public static void main(String[] args) {
    Person person = new Person();
    person.setName("Deepspace");
    person.setAge(24);
    System.out.println(person.getName()); // Deepspace
    System.out.println(person.getAge()); // 24
  }
}

當然這只是一個很基礎的封裝,如果想封裝出一個好的程序,還得多花很多心思。

1、構造函數

什麼是構造函數呢?舉個通俗的例子來理解。

寶寶在出生的時候,有的寶寶是出生後再取名字;而有的父母會在寶寶出生之前就取好名字,這樣的寶寶一生下來就有名字了。

那在 Java 中,怎麼在對象被創建的時候就給對象賦值呢?答案就是通過構造函數對對象進行初始化。還是以給寶寶取名字爲例。

出生後再取名字的寶寶

public class Person {
  String name;
  int age;

  public static void main(String[] args) {
    Person person = new Person();
    person.name = "陳星星";
    person.age = 1;
    System.out.println("姓名: " + person.name + ". 年齡: " + person.age); // 姓名: 陳星星. 年齡: 1
  }
}

一生下來就有名字的寶寶:

public class Person {
  String name;
  int age;

  Person(String myName, int myAge) {
    this.name = myName;
    this.age = myAge;
  }

  public static void main(String[] args) {
    Person person = new Person("陳星星", 1);
    System.out.println("姓名: " + person.name + ". 年齡: " + person.age); // 姓名: 陳星星. 年齡: 1
  }
}

上面的代碼中,Person 類中的 Person() 方法就是構造函數,也叫作構造方法或者構造器

構造函數在類實例化的過程中自動執行,不需要手動調用。構造函數可以在類實例化的過程中做一些初始化的工作。

其實,每個類都有構造函數。如果沒有顯式地爲類定義構造函數,**Java 編譯器將會爲該類提供一個默認的構造函數。**如:

public class Person {
  String name;
  int age;

  // 編譯器會默認加上
  Person() {
  }

  public static void main(String[] args) {
    Person person = new Person();
    System.out.println("姓名: " + person.name + ". 年齡: " + person.age); // 姓名: null. 年齡: 0
  }
}

這一點可以查看編譯後的 *.class 文件來驗證。

2、構造函數與普通函數的區別

那構造函數和普通函數有什麼區別呢?

  • 構造函數的函數名要與類名一樣,而普通的函數只要符合標識符的命名規則即可;
  • 一般函數是用於定義對象應該具備的功能,而構造函數定義的是,對象在在創建時,應該具備的一些內容。也就是對象的初始化內容
  • 構造函數是在對象建立時由 jvm 調用(不能被顯示調用),用作對象初始化,不需要手動調用;而一般函數是對象建立後,當對象調用該功能時纔會執行,需要手動調用;
  • 構造函數沒有返回值類型,也就是說不能有返回值。

3、重載

什麼是重載呢?

重載(Overloading):方法名字相同,而參數(參數的個數或者類型)不同,返回類型可以相同也可以不同。

爲什麼會出現重載?

在大多數程序設計語言中要求爲每個方法提供唯一的標識符。如:不能使用 add () 的函數計算整數相加的和之後,又用一個名爲 add() 的函數去計算浮點數的和,即每個函數(方法)都要有唯一的名稱,但是我們又覺得 add() 這個方法名非常適合,不想更換。

若是 add() 函數可以被重載了,那麼就既可以計算整數也可以計算浮點數。所以我們可以這樣設計方法爲:

int add(int a, int b)

調用 add(10,10) 我們就可以知道是計算兩個整數相加。此時,我們又想計算兩個浮點數相加,如果想繼續使用 add 這個方法名,那就將 add() 方法重載:

double add(double a, double c)

這是重載出現的一個原因。

Java 裏,構造函數(也說構造器、構造方法)是強制重載方法出現的另一個原因。構造函數的名字由類名決定,那麼就只能有一個構造函數。但是,又想使用多種方式用於初始化對象該怎麼辦呢?那麼就只有重載構造函數,使得同名不同參的構造函數同時存在。

4、構造函數的重載

**通過重載,在一個類中可以定義多個構造函數,以進行不同的初始化。**比如有的孩子出生前,父母就會爲他/她選擇好以後要從事的職業,而有的孩子的父母會讓孩子長大後自己選擇喜歡的職業。看下面的例子:

public class Person {
  String name;
  int age;
  String job;

  Person(String myName, int myAge) {
    name = myName;
    age = myAge;
  }

  Person(String myName, int myAge, String myJob) {
    name = myName;
    age = myAge;
    job = myJob;
  }

  public static void main(String[] args) {
    Person person1 = new Person("陳星星", 1);

    Person person2 = new Person("Deepspace", 1, "software engineer");
    System.out.println("姓名: " + person1.name + ". 年齡: " + person1.age); // 姓名: 陳星星. 年齡: 1
    System.out.println("姓名: " + person1.name + ". 年齡: " + person1.age + ". 職業: " + person2.job); // 姓名: 陳星星. 年齡: 1. 職業: software engineer
  }
}

4、構造代碼塊

因爲構造函數會重載,那就會有一個問題。比如:如果需要每個小孩出生都要哭的話,那就需要在不同的構造函數中都調用 cry() 這個方法,這就會造成代碼重複的問題。怎麼解決呢?—— 構造代碼塊。

public class Person {
  String name;
  int age;
  String job;

  // 構造代碼塊
  {
    cry(); // 每個 Person 對象創建出來時都會執行這裏的代碼
  }

  Person(String myName, int myAge) {
    name = myName;
    age = myAge;
  }

  Person(String myName, int myAge, String myJob) {
    name = myName;
    age = myAge;
    job = myJob;
  }

  public static void main(String[] args) {
    Person person1 = new Person("陳星星", 1); // 哇哇哇....
    Person person2 = new Person("Deepspace", 1, "software engineer"); // 哇哇哇....

    System.out.println("姓名: " + person1.name + ". 年齡: " + person1.age); // 姓名: 陳星星. 年齡: 1
    System.out.println("姓名: " + person1.name + ". 年齡: " + person1.age + ". 職業: " + person2.job); // 姓名: 陳星星. 年齡: 1. 職業: software engineer
  }

  public void cry() {
    System.out.println("哇哇哇....");
  }
}

構造代碼塊的作用就是將所有構造函數中公共的信息進行抽取,用一對花括號包裹起來,每次創建對象時,構造代碼塊會對所有對象進行統一初始化。

二、繼承

我們先看看沒有繼承的時候,會怎樣寫代碼。

以學生類和老師類爲例,學生和老師都會喫飯、睡覺,如果沒有繼承,代碼是這樣的:

class Teacher {
  public void eat() {
    System.out.println("喫飯");
  }

  public void sleep() {
    System.out.println("睡覺");
  }
}

class Student {
  public void eat() {
    System.out.println("喫飯");
  }

  public void sleep() {
    System.out.println("睡覺");
  }
}

分別定義了 Teacher 類和 Student 類,喫飯和睡覺是學生和老師共有的行爲,但是卻寫了兩遍;並且,如果需要給 Teacher 類和 Student 類再添加一個 walk 方法,則需要給兩個類都分別添加,沒有一點的複用性可言,隨着邏輯變得複雜,代碼的可維護性也會變差。

有了繼承,上面的問題就很好解決了。

1、extends

Java 通過 extends 關鍵字來實現繼承。

學生和老師都是人類,人類都要喫飯和睡覺。所以我們可以提取一個叫作 Person 的類:

public class Person {
  public void eat() {
    System.out.println("喫飯");
  }

  public void sleep() {
    System.out.println("睡覺");
  }
}

然後讓 Student 類和 Teacher 類都繼承 Person 類:

class Teacher extends Person {
}

class Student extends Person {
}

public class Main {
  public static void main(String[] args) {
    Student s = new Student();
    s.eat(); // 喫飯
    s.sleep(); // 睡覺

    System.out.println("-------------");

    Teacher t = new Teacher();
    t.eat(); // 喫飯
    t.sleep(); // 睡覺
  }
}

這裏要注意:父類中通過 private 修飾的變量和方法不會被繼承,也就是說不能在子類中直接操作父類通過 private 修飾的變量以及方法。

通過繼承,我們實現了下面幾個好處:

  • 提高了代碼的複用性
    • 多個類相同的成員可以放到同一個類中
  • 提高了代碼的維護性
    • 如果功能的代碼需要修改,修改一處即可
  • 讓類與類之間產生了關係(是多態的前提)

但是繼承也帶來了一些弊端:

  • 類的耦合性很強,打破了封裝性
    • Person 類發生了更改或者出現了錯誤,Teacher 類和 Student 類就無法正常工作了。

所以,我們在編程的時候,需要優先考慮組合,謹慎使用繼承

2、單繼承、多層繼承

Java 只支持單繼承,不支持多繼承。也就是說一個類只能有一個父類,不可以有多個父類。

class SubDemo extends Demo {} // ok

class SubDemo extends Demo1,Demo2 … // error

但是 Java 支持多層繼承(單鏈條):

class A {}
class B extends A {}
class C extends B {}

3、繼承中成員變量之間的關係

繼承時,如果子類中的成員變量和父類中的成員變量名稱不一樣,那就很好辦,沒有任何問題發生;如果子類中的成員變量和父類中的成員變量名稱一樣,會發生什麼呢?看段代碼:

class Father {
  public int num = 10;
  public int num4 = 90;

  public void method() {
    int num = 50;
  }
}

class Son extends Father {
  public int num2 = 20;
  public int num = 30;

  public void show() {
    int num = 40;
    System.out.println(num); // 40
    System.out.println(num2); // 20
    // 找不到,報錯
    //System.out.println(num3);
  }
}

public class ExtendsDemo {
  public static void main(String[] args) {
    // 創建對象
    Son s = new Son();
    s.show();
    System.out.println(s.num); // 30
    System.out.println(s.num4); // 90
  }
}

所以,在子類方法中訪問一個變量的查找順序是:

  • 在子類方法的局部範圍找,有就使用;
  • 在子類的成員範圍找,有就使用;
  • 在父類的成員範圍找,有就使用;
  • 如果還找不到,就報錯。

2、重寫

爲什麼會出現重寫(Overriding)呢?看下面的例子:

class Animal {
  public void printWhoIAm() {
    System.out.println("Animal");
  }
}

public class Dog extends Animal {
  public static void main(String[] args) {
    Dog dog = new Dog();
    dog.printWhoIAm(); // Animal
  }
}

繼承時,當 dog 調用 printWhoIAm() 方法時,其實希望的是輸出 dog,而不是 Animal。要實現輸出 Dog,該怎麼辦?

想到了重載,可是重載要求被重載的方法具有不同的形參列表,所以這個方法行不通。

代碼中,dog 調用的 printWhoIAm() 是父類中的,在子類中如果可以重寫這個方法,那麼就可以實現目的了。於是,重寫(覆寫)便產生了。

**重寫可以解決父類方法在子類中不適用的問題 —— 讓子類重寫父類的方法。**我們用重寫解決上面的問題:

class Animal {
  public void printWhoIAm() {
    System.out.println("Animal");
  }
}

public class Dog extends Animal {
  public static void main(String[] args) {
    Dog dog = new Dog();
    dog.printWhoIAm(); // Dog
  }

  @Override
  public void printWhoIAm() {
    System.out.println("Dog");
  }
}

@Override 是僞代碼,表示重寫(當然不寫也可以),不過寫上有如下好處:

  1. 可以當註釋用,方便閱讀;
  2. 編譯器可以驗證 @Override 下面的方法名是否是父類中所有的,如果沒有則報錯;
  3. 如果沒寫 @Override,而下面的方法名又不是父類中的方法,這時編譯器是可以編譯通過的,因爲編譯器會認爲這個方法是子類中自己增加的方法。

所以,爲了避免發生錯誤,我們在重寫時,需要加上 @override

這裏需要特別注意:如果重寫時,子類中的方法和父類中方法,返回值類型不一致的時候,則會發生報錯。

另外,子類中的方法和父類中方法的參數不一致,則並不會發生重寫:

class Animal {
  private String name;

  public void setName(String name) {
    this.name = name;
  }

  public String getName(String hello) {
    return this.name + hello;
  }
}

class Dog extends Animal {
  public String getName() {
    return "123";
  }
}

public class App {
  public static void main(String[] args) {
    Dog dog = new Dog();
    dog.setName("小黃");
    System.out.println(dog.getName("hello"));
  }
}

可以看到,在 App 這個類中,當我們給 getName 傳遞參數時,執行的是 Animal 中的方法,而非 Dog 中的 getName 方法。也就是說如果參數不一致最後執行的就不是重寫的那個方法。

重寫時也不可以將父類公開的方法或變量改成私有(如將 public 改成 private ),否則也會報錯。也就是說重寫的時候,方法的訪問控制權限不能比父類更嚴格。

3、super

重寫可以解決父類方法在子類不適用的問題,但是會有另外一個問題:子類若是重寫了父類的方法,那麼父類原來的這個方法還可以被調用嗎?答案是可以的。可以使用 super 關鍵字。看下面的例子:

class Animal {
  public void printWhoIAm() {
    System.out.println("Animal");
  }
}

public class Dog extends Animal {
  public static void main(String[] args) {
    Dog dog = new Dog();
    dog.print();
  }

  public void print() {
    super.printWhoIAm(); // Animal
    printWhoIAm(); // Dog,這裏也可以使用 this.printWhoIAm();
  }

  @Override
  public void printWhoIAm() {
    System.out.println("Dog");
  }
}

super 代表父類存儲空間的標識,可以理解爲父類引用,可以操作父類的成員。

訪問父類中的屬性也是一樣的:

public class Animal {
  public String name;
}

public class Cat extends Animal {
  private String name;

  public Cat(String aname, String dname) {
    super.name = aname;    // 通過 super 關鍵字來訪問父類中的 name 屬性
    this.name = dname;    // 通過 this 關鍵字來訪問本類中的 name 屬性
  }

  public static void main(String[] args) {
    Animal cat = new Cat("動物", "喵星人");
    System.out.println(cat); // 我是動物,我的名字叫喵星人
  }

  @override
  public String toString() {
    return "我是" + super.name + ",我的名字叫" + this.name;
  }
}

使用 super 調用父類的構造函數

既然是父類的引用,那父類裏的構造函數,自然也是可以通過 super 來調用的:

class Person {
  private String name;
  private int age;
  private String sex;

  Person(String name, int age, String sex) {
    this.name = name;
    this.age = age;
    this.sex = sex;
  }

  public Person(String name) {
    this.name = name;
  }

  public String getName() {
    return name;
  }

  public int getAge() {
    return age;
  }

  public void setAge(int age) {
    this.age = age;
  }

  public String getSex() {
    return sex;
  }

  public void setSex(String sex) {
    this.sex = sex;
  }
}

class Student extends Person {
  private String stuNo;
  private String department;

  Student(String name, String stuNo) {
    super(name);    // 調用父類中含有一個參數的構造函數
    this.stuNo = stuNo;
  }

  Student(String name, int age, String sex, String stuno, String department) {
    super(name, age, sex);    // 調用父類中含有三個參數的構造函數
    this.stuNo = stuno;
    this.department = department;
  }

  public String getStuNo() {
    return stuNo;
  }

  public void setStuNo(String stuNo) {
    this.stuNo = stuNo;
  }

  public String getDepartment() {
    return department;
  }

  public void setDepartment(String department) {
    this.department = department;
  }
}

public class Main {
  public static void main(String[] args) {
    Student stu = new Student("Deepspace", "141508926");
    System.out.println(stu.getStuNo()); // 141508926
    System.out.println(stu.getName()); // Deepspace

    Student stu1 = new Student("chenxingxing", 23, "Male", "141508927", "Sales");
    System.out.println(stu1.getStuNo()); // 141508927
    System.out.println(stu1.getDepartment()); // Sales
    System.out.println(stu1.getAge()); // 23
    System.out.println(stu1.getSex()); // Male
  }
}

從上面的代碼可以看出,使用 super 直接調用父類中的構造函數,可以使書寫代碼更簡潔方便。

在繼承中使用構造函數時需要注意:

子類中所有的構造函數默認都會訪問父類中空參數的構造函數。因爲子類繼承父類之後,獲取到了父類的內容(屬性/字段),而這些內容在使用之前必須先初始化,所以子類初始化之前,一定要先調用父類的構造函數完成父類數據的初始化。可以用下面的代碼來驗證:

class Father {
  int age;

  public Father() {
    System.out.println("Father 的無參構造函數");
  }

  public Father(String name) {
    System.out.println("Father 的帶參構造函數");
  }
}

class Son extends Father {
  public Son() {
    //super();
    System.out.println("Son 的無參構造函數");
  }

  public Son(String name) {
    //super();
    System.out.println("Son 的帶參構造函數");
  }
}

public class ExtendsDemo {
  public static void main(String[] args) {
    Son s = new Son();
    System.out.println("------------");
    Son s2 = new Son("孩子");
  }
}

運行結果爲:

Father 的無參構造函數
Son 的無參構造函數
------------
Father 的無參構造函數
Son 的帶參構造函數

如果父類沒有無參構造函數,那麼子類的構造函數會出現什麼現象呢?直接報錯了,父類無法完成初始化。也就是說子類中一定要有一個去訪問了父類的構造函數,否則父類數據就沒有初始化。

有下面幾種方式解決:

  • 在父類中加一個無參構造函數,必須顯式定義;
  • 通過使用 super 關鍵字去顯示的調用父類的帶參構造函數;
  • 子類通過 this 去調用本類的其他構造函數(前提是子類這個構造函數滿足了前面兩者其一);

代碼如下:

class Father {
  int age;

//  public Father() {
//    System.out.println("Father 的無參構造函數");
//  }

  public Father(String name) {
    System.out.println("Father 的帶參構造函數");
  }
}

class Son extends Father {
  public Son() {
    super("Deepspace");
    System.out.println("Son 的無參構造函數");
  }

  public Son(String name) {
    this();
    System.out.println("Son 的帶參構造函數");
  }

  public Son(String name, int age) {
    this("Deepspace");
    System.out.println("Son 的帶參構造函數-1");
  }
}

這裏要注意:調用父類構造函數調用代碼必須放在子類構造函數中的第一行!目的是在初始化當前對象時,先保證了父類對象先初始化,防止異常。

同時,同一個構造函數裏面,是不能夠同時出現 super()this() 的,會發生衝突。

4、this

很多時候,初學者會把 Java 中的 superthis 混淆,這裏也介紹下它們兩個的區別。

當一個對象創建後,JVM 就會給這個對象分配一個引用自己的指針,這個指針就是 this。所以,this 只能用在非靜態方法中。

this 的主要用途有下面三種。

this.<屬性名>

大部分時候,一個方法訪問其他方法、成員變量時,無須使用 this 前綴;但如果方法裏有個局部變量和成員變量同名,則必須使用 this 前綴。

public class Teacher {
  private String name;

  public Teacher(String name) {
    this.name = name; // 這裏就需要使用到 this 來訪問成員變量
  }

  public static void main(String[] args) {
    Teacher teacher = new Teacher("Deepspace");
    System.out.println(teacher.name); // Deepspace
  }
}

this.<方法名>

this 關鍵字最大的作用就是讓類中一個方法,訪問該類裏的另一個方法或成員變量。

package packageOne;

public class Teacher {
  private String name;

  public Teacher(String name) {
    this.name = name; // 這裏就需要使用到 this 來訪問成員變量
  }

  public static void main(String[] args) {
    Teacher teacher = new Teacher("Deepspace");
    System.out.println(teacher.name);
    teacher.calling(); // I am teaching
  }

  public void calling() {
    this.teaching(); // 通過 this 調用其他方法,this 可以省略
  }

  private void teaching() {
    System.out.println("I am teaching");
  }
}

雖然可以省略調用 teaching() 方法之前的 this,但實際上這個 this 依然是存在的。

注意:對於 static 修飾的方法而言,可以使用類來直接調用該方法;如果在 static 修飾的方法中使用 this 關鍵字,此時 this 就無法指向合適的對象。所以,static 修飾的方法中不能使用 this 。並且 Java 語法規定,靜態成員不能直接訪問非靜態成員。

this 訪問構造函數:

this() 用來訪問本類的構造函數,如果括號內有參數,就是調用指定的有參構造函數。

public class Student {
  String name;

  // 無參構造函數(沒有參數的構造函數)
  public Student() {
    this("張三");
  }

  // 有參構造函數
  public Student(String name) {
    this.name = name;
  }

  public static void main(String[] args) {
    Student stu = new Student();
    stu.print();
  }

  // 輸出 name 和 age
  public void print() {
    System.out.println("姓名:" + name); // 姓名:張三
  }
}

注意:

  • this() 不能在普通方法中使用,只能寫在構造函數中;
  • 在構造函數中使用時,必須是第一條語句。

三、多態

先看看抽象類和接口這兩個概念。

1、抽象類和抽象方法

abstract 修飾的方法稱爲抽象方法,修飾的類稱爲抽象類

抽象方法是一種特殊的方法:它只有聲明,而沒有具體的實現,該方法的的具體實現由子類提供。抽象方法的聲明格式爲:

abstract void fun();

抽象方法必須用 abstract 關鍵字進行修飾。

如果一個類含有抽象方法,則稱這個類爲抽象類,抽象類必須在類前用 abstract 關鍵字修飾。

在《 Java 編程思想》一書中,將抽象類定義爲 包含抽象方法的類,但是後面發現如果一個類不包含抽象方法,只是用 abstract 修飾的話也是抽象類。也就是說抽象類不一定必須含有抽象方法。這個問題其實並不衝突,如果一個抽象類不包含任何抽象方法,爲何還要設計爲抽象類?所以抽象類這個概念(包含抽象方法的類)是沒有問題。

由於抽象類中含有無具體實現的方法(抽象方法),所以不能用抽象類創建對象

因此,抽象類就是爲了繼承而存在的。如果定義了一個抽象類,卻不去繼承它,那麼等於白白創建了這個抽象類,不能用它來做任何事情。

什麼時候要用到抽象類?

對於一個父類,如果它的某個方法在類中實現出來沒有任何意義,必須根據子類的實際需求來進行不同的實現,那麼就可以將這個方法聲明爲 abstract 方法,此時這個類也就成爲 abstract 類了。

包含抽象方法的類稱爲抽象類,但並不意味着抽象類中只能有抽象方法,它和普通類一樣,同樣可以擁有成員變量和普通的成員方法。

看個完整的例子:

abstract class Person {
  private String name;
  private int age;

  public Person(String name, int age) {
    this.name = name;
    this.age = age;
  }

  public String getName() {
    return this.name;
  }

  public void setName(String name) {
    this.name = name;
  }

  public int getAge() {
    return this.age;
  }

  public void setAge(int age) {
    this.age = age;
  }

  public abstract String getInfo(); // 抽象方法,父類實現沒有意義,所以把父類變成了抽象類
};

class Student extends Person {
  private String school;

  public Student(String name, int age, String school) {
    super(name, age);    // 指定要調用抽象類中有兩個參數的構造函數
    this.school = school;
  }

  public String getInfo() {
    return "姓名:" + super.getName() + ";年齡:" + super.getAge() + ";學校:" + this.getSchool();
  }

  public String getSchool() {
    return school;
  }

  public void setSchool(String school) {
    this.school = school;
  }
};

public class Main {
  public static void main(String[] args) {
    Student stu = new Student("張三", 30, "清華大學");
    System.out.println(stu.getInfo());
  }
}

2、接口

接口是一種引用類型,和類很相似。接口是功能的集合,同樣可看作是一種數據類型,是比抽象類更爲抽象的 ”類”

1)接口的定義

與定義類的 class 關鍵字不同,定義接口時需要使用 interface 關鍵字。定義接口所在的文件格式仍爲 .java 文件,編譯後仍然會產生 .class 文件。這點可以讓我們將接口看作是一種只包含了功能聲明的特殊類

**接口中只描述所應該具備的方法,並沒有具體實現,具體的實現由接口的實現類(相當於接口的子類)來完成。**這樣將功能的定義與實現分離,優化了程序設計。

一個簡單的接口:

public interface USB {
  String name = "USB";

  public String getName();
}

2)接口和類的區別

但是,在接口中聲明的變量和方法做了許多限制,這點和類大不相同:

  • 具有 public 訪問控制符的接口,允許任何類使用;沒有指定 public 的接口,其訪問將侷限於所屬的包;
  • 接口中無法定義普通的成員變量,接口中聲明的變量其實都是常量,接口中的變量聲明,將隱式地聲明爲 publicstaticfinal,即常量,所以接口中定義的變量必須初始化
  • 接口中聲明的方法,將隱式地聲明爲公有的(public)和抽象的(abstract
  • 接口中的方法不能設置成 private ,這樣會導致使用該接口的類不能實現該方法;
  • 接口沒有構造函數,不能被實例化
public interface MyInterface {
  String name;    // 不合法,變量 name 必須初始化
  int age = 20;    // 合法,等同於 public static final int age = 20;

  MyInterface() {
  }

  void getInfo();    // 方法聲明,等同於 public abstract void getInfo();
}

3)接口的實現

接口無法被實例化,但是可以被實現。一個實現接口的類,必須實現接口內所描述的所有方法否則該類就必須聲明爲抽象類。

實現接口使用 implements 關鍵字:

public interface IMath {
  int sum();    // 完成兩個數的相加

  int maxNum(int a, int b);    // 獲取較大的數
}

public class MathClass implements IMath {
  private int num1;    // 第 1 個操作數
  private int num2;    // 第 2 個操作數

  public MathClass(int num1, int num2) {
    // 構造函數
    this.num1 = num1;
    this.num2 = num2;
  }

  // 實現接口中的求和方法
  public int sum() {
    return num1 + num2;
  }

  // 實現接口中的獲取較大數的方法
  public int maxNum(int a, int b) {
    if (a >= b) {
      return a;
    } else {
      return b;
    }
  }
}

public class NumTest {
  public static void main(String[] args) {
    // 創建實現類的對象
    MathClass calc = new MathClass(100, 300);
    System.out.println("100 和 300 相加結果是:" + calc.sum()); // 100 和 300 相加結果是:400
    System.out.println("100 比較 300,哪個大:" + calc.maxNum(100, 300)); // 100 比較 300,哪個大:300
  }
}

一個接口不能夠實現另一個接口,但它可以繼承多個其他接口。子接口可以對父接口的方法和常量進行重寫。例如:

public interface A {
  void printA();
}

public interface B {
  void printB();
}

public interface C extends A, B {
  void printC();
}

class X implements C {
  public void printA() {
    System.out.println("A、Hello World!!!");
  }

  public void printB() {
    System.out.println("B、Hello Java");
  }

  public void printC() {
    System.out.println("C、Hello OOP");
  }
}

public class Main {
  public static void main(String[] args) {
    X x = new X();
    x.printA(); // A、Hello World!!!
    x.printB(); // B、Hello Java
    x.printC(); // C、Hello OOP
  }
}

接口的默認實現

JDK1.8 開始,接口的方法可以有默認實現了,而且不需要實現類去實現其方法,這樣的方法稱爲默認方法。

爲什麼要有這個新特性呢?

當需求變化,需要修改接口時候,那就要修改全部實現該接口的類,沒辦法在修改的同時不影響已有的實現,所以就引進了默認方法。

有了接口的默認實現,這樣子類對於該方法就不需要強制來實現,可以選擇使用默認的實現,也可以重寫自己的實現。當爲接口擴展方法時,只需要提供該方法的默認實現即可,至於對應的實現類可以重寫也可以使用默認的實現,這樣所有的實現類就不會報語法錯誤了。

public interface InterfaceA {
  default String getName() {
    return "a";
  }
}

使用 default 修飾符,可以實現添加接口的默認實現。接口的默認實現也使得接口的功能跟抽象類更爲接近。

"多繼承接口"

雖然 Java 不支持多繼承,但是一個類也可以同時實現多個接口。我麼先看下面的例子:

interface InterfaceA {
  default String getName() {
    return "a";
  }
}

interface InterfaceB {
  default String getName() {
    return "b";
  }
}

public class ImpClass implements InterfaceA, InterfaceB {
  public static void main(String[] args) {
    ImpClass c = new ImpClass();
    System.out.println(c.getName()); // ab
    System.out.println(((InterfaceA) c).getName()); // ab
    System.out.println(((InterfaceB) c).getName()); // ab
  }

  @Override
  public String getName() {
    //必須提供自己的實現
    return InterfaceA.super.getName() + InterfaceB.super.getName();
  }
}

使用接口可以實現 “多繼承” 。但是這樣會造成菱形問題,這也是 Java 沒有提供多繼承的原因。

菱形問題

Java 語言中一個類只能繼承一個父類,但是一個類可以實現多個接口。這樣就造成了菱形問題

什麼是【菱形問題( diamond problem )】呢?

假設我們有一個父接口 A,子接口 BC 都重寫了 A 中的方法 test()。此時又有一個 D 接口,同時繼承了 BC,那麼當 D 調用 test() 時,繼承的是哪個父接口的方法呢?如果沒有給出進一步的說明,編譯器是無法給出答案的。如圖所示:

multiple-inheritance-diamond

爲了解決這個問題,實現類必須顯示地指定要使用的方法,當然也可以重寫共享方法並提供自己的實現。

interface InterfaceA {
  default String getName() {
    return "a";
  }
}

interface InterfaceB extends InterfaceA {
  default String getName() {
    return "b";
  }
}

interface InterfaceC extends InterfaceA {
  default String getName() {
    return "c";
  }
}

public class ImpClass implements InterfaceB, InterfaceC {
  public static void main(String[] args) {
    ImpClass c = new ImpClass();
    System.out.println(c.getName()); // bc
    System.out.println(((InterfaceA) c).getName()); // bc
    System.out.println(((InterfaceB) c).getName()); // bc
  }

  @Override
  public String getName() {
    //必須顯示地指定要使用的方法
    return InterfaceB.super.getName() + InterfaceC.super.getName();
  }
}

Java 8 中引入了一種新的語法 X.super.method(),其中 X 是希望調用的 method 方法所在的父接口。

3、多態

簡單點說,**多態就是某一個事物,在不同時刻表現出來的不同狀態。**比如:水在不同環境下的狀態不同(液體,固體,氣體)。

Java 中的多態分爲兩種:

  • 編譯時多態(又稱靜態多態)
  • 運行時多態(又稱動態多態)

重載就是編譯時多態的一個例子,在編譯時就知道要調用的方法是哪個,所以「編譯時多態」的概念就是在編譯時就已經確定。

而我們通常所說的多態指的都是運行時多態,也就是編譯時不確定究竟調用哪個具體方法,一直到運行時才能確定。這也是爲什麼有時候多態方法又被稱爲延遲方法的原因。

怎麼理解運行時才能確定呢?

一個引用變量到底會指向哪個類的實例對象,該引用變量發出的方法調用到底是哪個類中實現的方法,必須在由程序運行期間才能決定。

這樣,我們不用修改源代碼,就可以讓引用變量綁定到各種不同的類上,從而讓該引用調用的具體方法隨之改變,讓程序可以選擇多個運行狀態,這就是多態性。

那要怎麼讓引用變量綁定到不同的類上呢?

答案是向上轉型。我們可以讓子類 Child 繼承父類 Father,然後編寫一個指向子類的父類類型引用,也就是:

Father son = new Son();

定義了一個對象 son ,它在編譯時的類型是 Father,而實際運行時的類型是 Son。這樣就相當於把引用變量 son 綁定到了兩個不同的類上面 —— Father 類 和 Son 類。

Java 中實現多態有兩種方式:

  • 基於繼承實現的多態
  • 基於接口實現的多態

下面我們通過具體的例子繼續理解多態。

基於繼承實現的多態

class Animal {
  private String name = "Animal";

  public static void barking() {
    System.out.println("Animal正在叫...");
  }

  public void eat() {
    System.out.println(name + "正在喫東西...");
    sleep();
  }

  public void sleep() {
    System.out.println(name + "正在睡覺...");
  }

  public void run() {
    System.out.println(name + "正在奔跑...");
  }
}

class Cat extends Animal {
  private String name = "Cat";

  public static void barking() {
    System.out.println("Cat正在叫...");
  }

  // 重載
  public void eat(String name) {
    System.out.println(name + "喫完了");
    sleep();
  }

  // 重寫
  @Override
  public void sleep() {
    System.out.println(name + "正在睡覺");
  }

  public void catchMouse() {
    System.out.println("抓老鼠");
  }
}

public class Main {
  public static void main(String[] args) {
    Animal miao = new Cat();
    miao.eat();

    miao.barking();

    miao.run();

//    animal.catchMouse(); // 不能調用 catchMouse
  }
}

分析下上面的代碼:

  • Animal 類中有 eatsleeprun 三個普通方法和一個靜態方法 barking

  • Cat 類繼承了 Animal, 重載了 eat 方法,重寫了非靜態方法 sleep,同時 Cat 內部也實現了一個 catchMouse 方法,也有一個靜態方法 barking

打印結果爲:

Animal正在喫東西...
Cat正在睡覺
Animal正在叫...
Animal正在奔跑...

看了打印結果,可能就有疑惑了,下面我們來逐一分析:

創建了一個對象 miao,它在編譯時的類型是 Animal,而實際運行時的類型是 Cat

在使用 miao 這個對象時,程序知道它是個 Animal,所以在調用對象成員的時候,可以調用 eat() 方法,所以打印的第一句很好理解;那爲什麼打印的第二句不是 Animal 中的方法,而是 Cat 中的方法呢?

代碼中,Cat 重載了 eat() 方法,重寫了 sleep() 方法。我們需要知道:

對於一個指向子類對象的父類引用,如果子類中重寫了父類的方法,那麼在通過父類引用調用這個方法的時候,編譯器會在編譯時把這個父類的方法引用動態綁定到實際類型的方法上去(也就是重寫之後的方法上);而那些沒有被子類重寫的方法,則會靜態綁定到父類的方法上去。

如果你想了解 JVM 是如何實現的,可以看這裏:https://www.ibm.com/developerworks/cn/java/j-lo-polymorph/

所以,當調用 Animal 中的 eat() 方法時,eat() 方法內調用的 sleep() 方法是 Cat 中的 sleep() 方法,這就是第二個打印結果爲 "Cat 正在睡覺" 的原因。

第三句打印結果輸出的依舊是 Animal 中的方法,這個就很好理解了,barking 是靜態方法,靜態方法不會被重寫。用 static 關鍵字修飾的方法和變量都是屬於類自己本身的,即使子類和父類中都有同樣的 static 方法和變量,他們是沒有任何關係的,是相互獨立的,不存在多態性。

第四句打印調用 Animal 類中的 run() 方法,這個就沒什麼多說的了。

那爲什麼程序無法調用 Cat 中的 catchMouse() 方法呢?因爲我們聲明的對象 miaoAnimal 類型,到了運行時期,miao 調用 catchMouse 方法時,Animal 中沒有這個方法,所以就會編譯不通過,而 eat 方法和 sleep 方法是存在的,所以不會報錯。也就是:聲明時的類型決定你「能不能調」那個方法。

結論:

所以,當父類引用指向子類對象時,只能調用那些父類中存在的方法,如果子類中對該方法進行了重寫,那麼在運行時就會動態調用子類中的方法,這樣一來,這個父類引用就既可以調用父類中的方法,也可以調用子類中的方法了(前提是重寫)。這就是多態的體現。

所以,從前面的描述中,我們可以總結出發生多態的條件

  • 繼承
  • 重寫
  • 父類引用指向子類對象(向上轉型)

多態的缺點:

當我們去調用子類中特有的屬性和方法時,會發生報錯,這個就是多態的缺點,即:即多態後不能使用子類特有的屬性和方法。這其實就是向上轉型的缺點

如果我們想要使用子類中特有的屬性和方法該怎麼辦呢?答案是強制類型轉換 —— 向下轉型。

public class Main {
  public static void main(String[] args) {
    Animal miao = new Cat();
    miao.eat();

    miao.barking();

    miao.run();

    Cat mew = (Cat) miao; // 強制類型轉換
    mew.catchMouse(); // 抓老鼠
  }
}

基於接口實現的繼承

接口的靈活性就在於**「規定一個類必須做什麼,而不管你如何做」**。我們可以定義一個接口類型的引用變量來引用實現接口的類的實例,當這個引用調用方法時,它會根據實際引用的類的實例來判斷具體調用哪個方法。看下面的例子:

interface Animal {
  public void eat();

  public void walk();
}

class Cat implements Animal {
  public void eat() {
    System.out.println("貓在喫!!");
  }

  public void walk() {
    System.out.println("貓在走!!");
  }
}

public class Dog implements Animal {
  public void eat() {
    System.out.println("狗在喫!!");
  }

  public void walk() {
    System.out.println("狗在走!!");
  }
  
  public void sleep() {
    System.out.println("狗睡覺!!");
  }
}

public class Main {
  public static void main(String[] args) {
    // 向上轉型
    Animal d = new Dog(); // 接口的引用類型變量(d)指向了接口實現類的對象(Dog)。
    d.eat(); // 狗在喫!!
    d.walk(); // 狗在走!!
    // d.sleep(); // error

    Animal c = new Cat();
    c.eat(); // 貓在喫!!
    c.walk(); // 貓在走!!
  }
}

充分體現了 “一個接口,多個實現” 的特點。

4、經典例子

檢驗自己是否掌握了多態,看看下面的經典例子吧:

class A {
  public String show(D obj) {
    return "A and D";
  }

  public String show(A obj) {
    return "A and A";
  }
}

class B extends A {
  public String show(B obj) {
    return "B and B";
  }

  public String show(A obj) {
    return "B and A";
  }
}

class C extends B {

}

class D extends B {

}

public class Main {
  public static void main(String[] args) {
    A a1 = new A();
    A a2 = new B();
    B b = new B();
    C c = new C();
    D d = new D();

    System.out.println(a1.show(b)); // 1 
    System.out.println(a1.show(c)); // 2 
    System.out.println(a1.show(d)); // 3 
    System.out.println(a2.show(b)); // 4 
    System.out.println(a2.show(c)); // 5 
    System.out.println(a2.show(d)); // 6 
    System.out.println(b.show(b)); // 7 
    System.out.println(b.show(c)); // 8 
    System.out.println(b.show(d)); // 9 
  }
}

輸出結果是:

A and A 
A and A 
A and D 
B and A 
B and A 
A and D 
B and B 
B and B 
A and D
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章