Java 內部類的意義及應用

YangAM發表於2018-04-17

眾所周知,我們的 C++ 程式語言是多繼承製的,而多繼承明顯的好處就是,相對而言只需要寫較少的程式碼即可完成一個類的定義,因為我們可以通過繼承其它類來獲取別人的實現。

但是,它也有一個致命性的缺陷,容易出現「鑽石繼承結構」,例如:

image

C 和 D 繼承自 A,並得到 A 的 name 屬性,那麼如果有一個類 B 多繼承自 C 和 D,請問 D 該如何取捨這兩個相同的屬性欄位?

一般這種情況下,編譯器會提示錯誤,以警示程式設計師修改程式碼。當然,C++ 通過 virtual 關鍵字以虛擬繼承的方式解決了這個問題,具體細節大家可以自行參照 C++ 的語法進行了解。

但是,Java 從一開始就覺得 C++ 的多繼承會是一個「麻煩」,所以 Java 是單根繼承機制,不允許多繼承。網上看到有人用一個詞評論了 sun 公司的這種做法,覺得挺貼切的,叫「矯枉過正」,多繼承也不是一無是處,在一些需要大量複用程式碼的情境下,也不失為一個好的解決方式。

所以,jdk 推出了「內部類」的概念,當然,內部類不僅僅彌補了 Java 不能多繼承的一個不足,通過將一個類定義在另一個類的內部,也可以有效的隱藏該類的可見性,等等。

介面 + 內部類 = 多繼承

在這之前,Java 的繼承機制主要由介面和單根繼承實現,通過實現多個介面裡的方法,看似能夠實現多繼承,但是並不總是高效的,因為一旦我們繼承了一個介面就必然要實現它內部定義的所有方法。

現在我們可以通過內部類多次繼承某個具體類或者介面,省去一些不必要的實現動作。只能說 Java 的內部類完善了它的多繼承機制,而不是主要實現,因為內部類終究是一種破壞封裝性的設計,除非有很強的把控能力,否則還是越少用越好

我們看一段程式碼:

public class Father {
    public String powerFul = "市長";
}

public class Mother {
    public String wealthy = "一百萬";
}
複製程式碼
public class Son {
    class Extends_Father extends Father{
    }

    class Extends_Mother extends Mother{
    }

    public void sayHello(){
        String father = new Extends_Father().powerFul;
        String mother = new Extends_Mother().wealthy;
        System.out.println("my father is:" + father + "my mother has:" + mother);
    }
}
複製程式碼

顯然,我們的 Son 類是不可能同時繼承 Father 和 Mother 的,但是我們卻可以通過在其內部定義內部類繼承了 Father 和 Mother,必要的情況下,我們還能夠重寫繼承而來的各個類的屬性或者方法。

這就是典型的一種通過內部類實現多繼承的實現方式,但是同時你也會發現,單單從 Son 來外表看,你根本不知道它內部多繼承了 Father 和 Mother,從而往往會給我們帶來一些錯覺。所以你看,內部類並不絕對是一個好東西,它破壞了封裝性,用的不好反而會適得其反,讓你的程式一團糟,所以謹慎!

當然,並不是貶低它的價值,有些情況下它也能給你一種「四兩撥千斤」的感覺,省去很多麻煩。下面我們看看幾種不同的內部類型別。

靜態內部類

靜態內部類通過對定義在外部類內部的類加上關鍵字「static」進行修飾,以標示一個靜態內部類,例如:

public class OuterClass {
    private static String name = "hello world";
    private int age = 23;

    public static class MyInnerClass{
        private static String myName = "single";
        private int myAge = 23;

        public void sayHello(){
            System.out.println(name);
            //編譯器報錯提示:不可訪問的欄位 age
            System.out.println(age);
        }
    }
}
複製程式碼

首先,MyInnnerClass 作為一個內部類,它可以定義自己的靜態屬性,靜態方法,例項屬性,例項方法,和普通類一樣。

此外,由於 MyInnerClass 作為一個內部類,它對於外部類 OuterClass 中部分成員也是可見的,但並全部可見,不同型別的內部類可見的外部類成員不盡相同,例如我們的靜態內部類對於外部類的以下成員時可見的:

  • 靜態屬性
  • 靜態方法

所以,我們上述的例子中,外部類 OuterClass 的例項屬性 age 對於靜態內部類 MyInnerClass 是不可見的。

那麼 Java 是如何做到在一個類的內部定義另一個類的呢?

實際上編譯器在編譯我們的外部類的時候,會掃描其內部是否還存在其他型別的定義,如果有那麼會「蒐集」這些類的程式碼,並按照某種特殊名稱規則單獨編譯這些類。正如我們上述的 MyInnerClass 內部類會被單獨編譯成 OuterClass$MyInnerClass.class 檔案。

image

當然,這裡的特殊命名規則其實就是:外部類名 + $ + 內部類名

那麼,既然內部類會被單獨編譯出來,那它如何保持與外部類的聯絡呢,我們反編譯一下位元組碼檔案。

image

image

由於靜態內部類內部只能訪問它的外部內的靜態成員,而對於訪問許可權可見的情況下,這兩個類本質上毫無關聯,但如果像我們此例中的外部類屬性 name 而言,它本身被修飾為 private,不可見於外部的任何類。

但是對於某個外部類的內部類而言,即便是被修飾為 private 的成員,它應當也是可見於內部類的任意位置的。

所以我們的編譯器「偷偷的」做了一件事情,為被修飾為 private 的靜態欄位 name 提供一個包範圍可見的靜態方法,返回對 name 的引用,正如我們這裡的方法:access$000 一樣。

你當然也可以猜測出,如果是修改 name 值的操作,想必也會對應一個這樣的方法用於設定私有成員的屬性值。

如果你想要在外部直接建立一個靜態內部類的例項,也是被允許的。例如:

public static void main(String[] args){
    //建立靜態內部類例項
    OuterClass.MyInnerClass innerClass = new OuterClass.MyInnerClass();
    innerClass.sayHello();
}
複製程式碼

當然,這樣的操作一般也不被推薦,因為一個內部類既然被定義在某個外圍類的內部,那它一定是為這個外圍類服務的,而你從外部越過外圍類而單獨建立內部類的實現顯然是不符合物件導向設計思想的。

靜態內部類的應用場景其實還是很多的,但有一個基本的設計準則是,靜態內部類不需要依賴外圍類的例項,獨立於外圍類,為外圍類提供服務。

例如我們 Integer 類中的 IntegerCache 就是一個靜態的內部類,它不需要訪問外圍類中任何成員,卻通過內部定義的一些屬性和方法為外圍類提供快取服務。

成員內部類

成員內部類不使用「static」關鍵字修飾,但卻與「靜態內部類」有著截然不同的特性。例如:

public class OuterClass {
    private static String tel = "23434324";
    private int age = 23;

    public class MyInnerClass{
        //編譯不通過,非靜態的內部類是不允許擁有靜態的屬性和方法的
        private static String name;
        private String name2 = "hello";

        public void sayHello(){
            System.out.println(tel);
            System.out.println(age);
        }
    }
}
複製程式碼

成員內部類的例項建立需要依賴外圍類,也就是沒有外圍類例項就不會有內部類例項,外圍類的靜態或非靜態成員對於成員內部類而言全部可見。

但是成員內部類之中不允許定義靜態成員,原因也很簡單,假如允許定義靜態成員,那麼我們下面這條語句必然是可行的。

System.out.println(OuterClass.MyInnerClass.name);
複製程式碼

但是我們說,既然成員內部類必須關聯一個外圍類例項,那麼這種不需要依賴外圍類例項即可操作內部類的方式是不是有點違背設計了呢?

於是 Java 乾脆不允許成員內部類中定義靜態的成員。

當然,如果你想要從外部直接建立一個成員內部類的例項,你可以這樣做:

public static void main(String[] args){
    OuterClass outerClass = new OuterClass();
    OuterClass.MyInnerClass myInnerClass = outerClass.new MyInnerClass();
    myInnerClass.sayHello();
}
複製程式碼

同樣的,Java 並不推薦這樣使用內部類,內部類更適合作為一種工具提供給它的外圍類。

接著,我們看看成員內部類的實現原理:

image

內部類:

image

我們先看內部類的構造器,實際上每當例項化一個內部類例項的時候,都會傳入一個外圍類例項引用作為構造引數,內部類儲存這個例項引用並通過它訪問該引用所對應的外圍類成員屬性。

成員內部類與靜態內部類最大的不同點就在於,成員內部類高度依賴一個外圍類例項,並且不允許定義任何靜態成員,而靜態內部類與外圍類趨於獨立。

區域性內部類

區域性內部類就是在程式碼塊中定義一個類,最典型的應用是在方法中定義一個類。例如:

public class Method {
    private static String name;
    private int age;

    public void hello(){
        class MyInnerClass{
            public void sayHello(){
                System.out.println(name);
                System.out.println(age);
            }
        }
    }
}
複製程式碼

區域性內部類中是可以訪問外圍類的相關屬性或者方法的,但是往往限制於外圍的方法。如果方法是例項方法,那麼方法內的內部類可以訪問外圍類的任意成員,如果方法是靜態方法,那麼方法內部的內部類只能訪問外圍類的靜態成員。

考慮另一種情況,當方法具有引數或方法內定義了區域性變數,那麼我們的區域性內部類還能夠訪問到它們嗎?

public class Method2 {
    public void hello(String name){
        int age = 23;
        class MyInnerClass{
            public void sayHello(){
                System.out.println(name);
                System.out.println(age);
            }
        }
    }
}
複製程式碼

答案是能的,我們看一下它的反編譯程式碼:

image

同樣的套路,通過構造器傳入外圍類例項以實現內部類對外圍類成員的訪問。除此之外,如果外圍類的方法中有引數或者定義了區域性變數,編譯器會蒐集並在構建區域性內部類例項的時候全部傳入。

但是,這裡有一個坑大家需要注意一下。雖然這裡的 name 和 age 並沒有被宣告為 final,但是程式是不允許你修改它們的值的。也就是說,它們被預設新增了 final 修飾符。

為什麼這麼做?

從我們反編譯的結果來看,區域性內部類中只儲存的這些變數的數值,而不是記憶體地址,並且也不允許更改,那麼如果外部的這些變數可更改,將直接導致每個新建內部類的例項具有不同的屬性值,所以直接給宣告為 final,不允許你修改。

(這個特性以前貌似是需要程式設計師手動新增 final 進行修飾的,現在好像是預設的,害我還鬱悶了半天,為什麼不加 final 也能通過編譯。。後來手動改它的值,發現不能改)

匿名內部類

匿名內部類,顧名思義,是沒有名字的類,那麼既然它沒有名字,自然也就無法顯式的建立出其例項物件了,所以匿名內部類適合那種只使用一次的情境,例如:

image

這就是一個典型的匿名內部類的使用,它等效於下面的程式碼:

public class MyObj extends Object{
    @Override 
    public String toString(){
        return "hello world";
    }
}
複製程式碼
public static void main(String[] args){
    Object obj = new MyObj();
}
複製程式碼

為了一個只使用一次的類而單獨建立一個 .java 檔案,是否有些浪費和繁瑣?

在我看來,匿名內部類最大的好處就在於能夠簡化程式碼塊

匿名類的基本使用語法格式如下:

new 父類/介面{
    //匿名類的實現
}
複製程式碼

匿名內部類往往是對某個父類或者介面的繼承與實現,我們再看一段程式碼:

public static void main(String[] args){
    Date date = new Date(123313){
        @Override
        public String toString(){
            return "hello";
        }
    };
}
複製程式碼

我們這裡定義了一個匿名內部類,實現了父類 Date,並重寫了其 toString 方法。我們反編譯一下:

image

顯然,我們匿名內部類的構造器會呼叫相對應的父類構造器進行父類成員的初始化動作。而匿名內部類的本質也就這樣,只是你看不到名字而已,其實編譯器還是會為它生成單獨的一份 Class 檔案並擁有唯一的類名的。

其實你從編譯器的層面上看,匿名內部類和一個實際的型別相差無幾,它也能繼承某個類並重寫其中方法,實現某個介面的所有方法等。最吸引人的可能就是它無需單獨建立類檔案的簡便性。

說句實話,內部類在實際的開發中並不常見,甚至被某些公司抵制使用,因為一旦你使用的不好很可能導致整個專案程式碼混亂不堪,不易於排查錯誤。但是如果你用的好的話,往往會給你有一種「巧勁」,你就比如我們的 jdk 原始碼,幾乎每個類中都定義有一至多個內部類,並且相互之間不存在問題,很高效。

所以,內部類的使用還是適情況,適人而定,但是看的懂內部類卻是你應當具有的能力,這也是本篇文章的目標。


文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公眾號:撲在程式碼上的高爾基,所有文章都將同步在公眾號上。

image

相關文章