1. 程式人生 > >詳解 Java 內部類

詳解 Java 內部類

轉自:[指點]:https://blog.csdn.net/hacker_zhidian/article/details/82193100

*本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出

文章目錄

前言

內部類在 Java 裡面算是非常常見的一個功能了,在日常開發中我們肯定多多少少都用過,這裡總結一下關於 Java 中內部類的相關知識點和一些使用內部類時需要注意的點。
從種類上說,內部類可以分為四類:普通內部類、靜態內部類、匿名內部類、區域性內部類。我們來一個個看:

普通內部類

這個是最常見的內部類之一了,其定義也很簡單,在一個類裡面作為類的一個欄位直接定義就可以了,例:

public class InnerClassTest {

    public class InnerClassA {
        
    }
}

在這裡 InnerClassA 類為 InnerClassTest 類的普通內部類,在這種定義方式下,普通內部類物件依賴外部類物件而存在,即在建立一個普通內部類物件時首先需要建立其外部類物件,我們在建立上面程式碼中的 InnerClassA 物件時先要建立 InnerClassTest 物件,例:

public
class InnerClassTest { public int outField1 = 1; protected int outField2 = 2; int outField3 = 3; private int outField4 = 4; public InnerClassTest() { // 在外部類物件內部,直接通過 new InnerClass(); 建立內部類物件 InnerClassA innerObj = new InnerClassA(); System.out.println("建立 "
+ this.getClass().getSimpleName() + " 物件"); System.out.println("其內部類的 field1 欄位的值為: " + innerObj.field1); System.out.println("其內部類的 field2 欄位的值為: " + innerObj.field2); System.out.println("其內部類的 field3 欄位的值為: " + innerObj.field3); System.out.println("其內部類的 field4 欄位的值為: " + innerObj.field4); } public class InnerClassA { public int field1 = 5; protected int field2 = 6; int field3 = 7; private int field4 = 8; // static int field5 = 5; // 編譯錯誤!普通內部類中不能定義 static 屬性 public InnerClassA() { System.out.println("建立 " + this.getClass().getSimpleName() + " 物件"); System.out.println("其外部類的 outField1 欄位的值為: " + outField1); System.out.println("其外部類的 outField2 欄位的值為: " + outField2); System.out.println("其外部類的 outField3 欄位的值為: " + outField3); System.out.println("其外部類的 outField4 欄位的值為: " + outField4); } } public static void main(String[] args) { InnerClassTest outerObj = new InnerClassTest(); // 不在外部類內部,使用:外部類物件. new 內部類構造器(); 的方式建立內部類物件 // InnerClassA innerObj = outerObj.new InnerClassA(); } }

這裡的內部類就像外部類宣告的一個屬性欄位一樣,因此其的物件時依附於外部類物件而存在的,我們來看一下結果:
在這裡插入圖片描述
我們注意到,內部類物件可以訪問外部類物件中所有訪問許可權的欄位,同時,外部類物件也可以通過內部類的物件引用來訪問內部類中定義的所有訪問許可權的欄位,後面我們將從原始碼裡面分析具體的原因。
我們下面來看一下靜態內部類:

靜態內部類

我們知道,一個類的靜態成員獨立於這個類的任何一個物件存在,只要在具有訪問許可權的地方,我們就可以通過 類名.靜態成員名 的形式來訪問這個靜態成員,同樣的,靜態內部類也是作為一個外部類的靜態成員而存在,建立一個類的靜態內部類物件不需要依賴其外部類物件。例:

public class InnerClassTest {
	public int field1 = 1;
    
	public InnerClassTest() {
		System.out.println("建立 " + this.getClass().getSimpleName() + " 物件");
        // 建立靜態內部類物件
        StaticClass innerObj = new StaticClass();
        System.out.println("其內部類的 field1 欄位的值為: " + innerObj.field1);
        System.out.println("其內部類的 field2 欄位的值為: " + innerObj.field2);
        System.out.println("其內部類的 field3 欄位的值為: " + innerObj.field3);
        System.out.println("其內部類的 field4 欄位的值為: " + innerObj.field4);
    }
	
    static class StaticClass {

        public int field1 = 1;
        protected int field2 = 2;
        int field3 = 3;
        private int field4 = 4;
        // 靜態內部類中可以定義 static 屬性
        static int field5 = 5;

        public StaticClass() {
            System.out.println("建立 " + StaticClass.class.getSimpleName() + " 物件");
//            System.out.println("其外部類的 field1 欄位的值為: " + field1); // 編譯錯誤!!
        }
    }

    public static void main(String[] args) {
	    // 無需依賴外部類物件,直接建立內部類物件
//        InnerClassTest.StaticClass staticClassObj = new InnerClassTest.StaticClass();
		InnerClassTest outerObj = new InnerClassTest();
    }
}

結果:
這裡寫圖片描述

可以看到,靜態內部類就像外部類的一個靜態成員一樣,建立其物件無需依賴外部類物件(訪問一個類的靜態成員也無需依賴這個類的物件,因為它是獨立於所有類的物件的)。但是於此同時,靜態內部類中也無法訪問外部類的非靜態成員,因為外部類的非靜態成員是屬於每一個外部類物件的,而本身靜態內部類就是獨立外部類物件存在的,所以靜態內部類不能訪問外部類的非靜態成員,而外部類依然可以訪問靜態內部類物件的所有訪問許可權的成員,這一點和普通內部類無異。

匿名內部類

匿名內部類有多種形式,其中最常見的一種形式莫過於在方法引數中新建一個介面物件 / 類物件,並且實現這個介面宣告 / 類中原有的方法了:

public class InnerClassTest {

    public int field1 = 1;
    protected int field2 = 2;
    int field3 = 3;
    private int field4 = 4;

    public InnerClassTest() {
        System.out.println("建立 " + this.getClass().getSimpleName() + " 物件");
    }
    // 自定義介面
	interface OnClickListener {
        void onClick(Object obj);
    }

    private void anonymousClassTest() {
        // 在這個過程中會新建一個匿名內部類物件,
        // 這個匿名內部類實現了 OnClickListener 介面並重寫 onClick 方法
        OnClickListener clickListener = new OnClickListener() {
	        // 可以在內部類中定義屬性,但是隻能在當前內部類中使用,
	        // 無法在外部類中使用,因為外部類無法獲取當前匿名內部類的類名,
	        // 也就無法建立匿名內部類的物件
	        int field = 1;
	        
            @Override
            public void onClick(Object obj) {
                System.out.println("物件 " + obj + " 被點選");
                System.out.println("其外部類的 field1 欄位的值為: " + field1);
                System.out.println("其外部類的 field2 欄位的值為: " + field2);
                System.out.println("其外部類的 field3 欄位的值為: " + field3);
                System.out.println("其外部類的 field4 欄位的值為: " + field4);
            }
        };
        // new Object() 過程會新建一個匿名內部類,繼承於 Object 類,
        // 並重寫了 toString() 方法
        clickListener.onClick(new Object() {
            @Override
            public String toString() {
                return "obj1";
            }
        });
    }

    public static void main(String[] args) {
        InnerClassTest outObj = new InnerClassTest();
        outObj.anonymousClassTest();
    }
}

來看看結果:
這裡寫圖片描述
上面的程式碼中展示了常見的兩種使用匿名內部類的情況:
1、直接 new 一個介面,並實現這個介面宣告的方法,在這個過程其實會建立一個匿名內部類實現這個介面,並重寫介面宣告的方法,然後再建立一個這個匿名內部類的物件並賦值給前面的 OnClickListener 型別的引用;
2、new 一個已經存在的類 / 抽象類,並且選擇性的實現這個類中的一個或者多個非 final 的方法,這個過程會建立一個匿名內部類物件繼承對應的類 / 抽象類,並且重寫對應的方法。

同樣的,在匿名內部類中可以使用外部類的屬性,但是外部類卻不能使用匿名內部類中定義的屬性,因為是匿名內部類,因此在外部類中無法獲取這個類的類名,也就無法得到屬性資訊。

區域性內部類

區域性內部類使用的比較少,其宣告在一個方法體 / 一段程式碼塊的內部,而且不在定義類的定義域之內便無法使用,其提供的功能使用匿名內部類都可以實現,而本身匿名內部類可以寫得比它更簡潔,因此區域性內部類用的比較少。來看一個區域性內部類的小例子:

public class InnerClassTest {

    public int field1 = 1;
    protected int field2 = 2;
    int field3 = 3;
    private int field4 = 4;

    public InnerClassTest() {
        System.out.println("建立 " + this.getClass().getSimpleName() + " 物件");
    }
    
    private void localInnerClassTest() {
	    // 區域性內部類 A,只能在當前方法中使用
        class A {
	        // static int field = 1; // 編譯錯誤!區域性內部類中不能定義 static 欄位
            public A() {
	            System.out.println("建立 " + A.class.getSimpleName() + " 物件");
                System.out.println("其外部類的 field1 欄位的值為: " + field1);
                System.out.println("其外部類的 field2 欄位的值為: " + field2);
                System.out.println("其外部類的 field3 欄位的值為: " + field3);
                System.out.println("其外部類的 field4 欄位的值為: " + field4);
            }
        }
        A a = new A();
        if (true) {
	        // 區域性內部類 B,只能在當前程式碼塊中使用
            class B {
                public B() {
	                System.out.println("建立 " + B.class.getSimpleName() + " 物件");
                    System.out.println("其外部類的 field1 欄位的值為: " + field1);
                    System.out.println("其外部類的 field2 欄位的值為: " + field2);
                    System.out.println("其外部類的 field3 欄位的值為: " + field3);
                    System.out.println("其外部類的 field4 欄位的值為: " + field4);
                }
            }
            B b = new B();
        }
//        B b1 = new B(); // 編譯錯誤!不在類 B 的定義域內,找不到類 B,
    }

    public static void main(String[] args) {
        InnerClassTest outObj = new InnerClassTest();
        outObj.localInnerClassTest();
    }
}

同樣的,在區域性內部類裡面可以訪問外部類物件的所有訪問許可權的欄位,而外部類卻不能訪問區域性內部類中定義的欄位,因為區域性內部類的定義只在其特定的方法體 / 程式碼塊中有效,一旦出了這個定義域,那麼其定義就失效了,就像程式碼註釋中描述的那樣,即外部類不能獲取區域性內部類的物件,因而無法訪問區域性內部類的欄位。最後看看執行結果:
這裡寫圖片描述

內部類的巢狀

內部類的巢狀,即為內部類中再定義內部類,這個問題從內部類的分類角度去考慮比較合適:
普通內部類:在這裡我們可以把它看成一個外部類的普通成員方法,在其內部可以定義普通內部類(巢狀的普通內部類),但是無法定義 static 修飾的內部類,就像你無法在成員方法中定義 static 型別的變數一樣,當然也可以定義匿名內部類和區域性內部類;

靜態內部類:因為這個類獨立於外部類物件而存在,我們完全可以將其拿出來,去掉修飾它的 static 關鍵字,他就是一個完整的類,因此在靜態內部類內部可以定義普通內部類,也可以定義靜態內部類,同時也可以定義 static 成員;

匿名內部類:和普通內部類一樣,定義的普通內部類只能在這個匿名內部類中使用,定義的區域性內部類只能在對應定義域內使用;

區域性內部類:和匿名內部類一樣,但是巢狀定義的內部類只能在對應定義域內使用。

深入理解內部類

不知道小夥伴們對上面的程式碼有沒有產生疑惑:非靜態內部類可以訪問外部類所有訪問許可權修飾的欄位(即包括了 private 許可權的),同時,外部類也可以訪問內部類的所有訪問許可權修飾的欄位。而我們知道,private 許可權的欄位只能被當前類本身訪問。然而在上面我們確實在程式碼中直接訪問了對應外部類 / 內部類的 private 許可權的欄位,要解除這個疑惑,只能從編譯出來的類下手了,為了簡便,這裡採用下面的程式碼進行測試:

public class InnerClassTest {
	
	int field1 = 1;
	private int field2 = 2;
	
	public InnerClassTest() {
		InnerClassA inner = new InnerClassA();
		int v = inner.x2;
	}
	
    public class InnerClassA {
		int x1 = field1;
		private int x2 = field2;
    }
}

我在外部類中定義了一個預設訪問許可權(同一個包內的類可以訪問)的欄位 field1, 和一個 private 許可權的欄位 field2 ,並且定義了一個內部類 InnerClassA ,並且在這個內部類中也同樣定義了兩個和外部類中定義的相同修飾許可權的欄位,並且訪問了外部類對應的欄位。最後在外部類的構造方法中我定義了一個方法內變數賦值為內部類中 private 許可權的欄位。我們用 javac 命令(javac InnerClassTest.java)編譯這個 .java 檔案,會得到兩個 .classs 檔案:
InnerClassTest.classInnerClassTest$InnerClassA.class,我們再用 javap -c 命令(javap -c InnerClassTestjavap -c InnerClassTest$InnerClassA)分別反編譯這兩個 .class 檔案,InnerClassTest.class 的位元組碼如下:

這裡寫圖片描述
我們注意到位元組碼中多了一個預設修飾許可權並且名為 access$100 的靜態方法,其接受一個 InnerClassTest 型別的引數,即其接受一個外部類物件作為引數,方法內部用三條指令取到引數物件的 field2 欄位的值並返回。由此,我們現在大概能猜到內部類物件是怎麼取到外部類的 private 許可權的欄位了:就是通過這個外部類提供的靜態方法。
類似的,我們注意到 24 行位元組碼指令 invokestatic ,這裡代表執行了一個靜態方法,而後面的註釋也寫的很清楚,呼叫的是 InnerClassTest$InnerClassA.access$000 方法,即呼叫了內部類中名為 access$000 的靜態方法,根據我們上面的外部類位元組碼規律,我們也能猜到這個方法就是內部類編譯過程中編譯器自動生成的,那麼我們趕緊來看一下 InnerClassTest$InnerClassA 類的位元組碼吧:
這裡寫圖片描述
果然,我們在這裡發現了名為 access$000 的靜態方法,並且這個靜態方法接受一個 InnerClassTest$InnerClassA 型別的引數,方法的作用也很簡單:返回引數代表的內部類物件的 x2 欄位值。
我們還注意到編譯器給內部類提供了一個接受 InnerClassTest 型別物件(即外部類物件)的構造方法,內部類本身還定義了一個名為 this$0InnerClassTest 型別的引用,這個引用在構造方法中指向了引數所對應的外部類物件。
最後,我們在 25 行位元組碼指令發現:內部類的構造方法通過 invokestatic 指令執行外部類的 access$100 靜態方法(在 InnerClassTest 的位元組碼中已經介紹了)得到外部類物件的 field2 欄位的值,並且在後面賦值給 x2 欄位。這樣的話內部類就成功的通過外部類提供的靜態方法得到了對應外部類物件的 field2

上面我們只是對普通內部類進行了分析,但其實匿名內部類和區域性內部類的原理和普通內部類是類似的,只是在訪問上有些不同:外部類無法訪問匿名內部類和區域性內部類物件的欄位,因為外部類根本就不知道匿名內部類 / 區域性內部類的型別資訊(匿名內部類的類名被隱匿,區域性內部類只能在定義域內使用)。但是匿名內部類和區域性內部類卻可以訪問外部類的私有成員,原理也是通過外部類提供的靜態方法來得到對應外部類物件的私有成員的值。而對於靜態內部類來說,因為其實獨立於外部類物件而存在,因此編譯器不會為靜態內部類物件提供外部類物件的引用,因為靜態內部類物件的建立根本不需要外部類物件支援。但是外部類物件還是可以訪問靜態內部類物件的私有成員,因為外部類可以知道靜態內部類的型別資訊,即可以得到靜態內部類的物件,那麼就可以通過靜態內部類提供的靜態方法來獲得對應的私有成員值。來看一個簡單的程式碼證明:

public class InnerClassTest {
	
	int field1 = 1;
	private int field2 = 2;
	
	public InnerClassTest() {
		InnerClassA inner = new InnerClassA();
		int v = inner.x2;
	}
	
	// 這裡改成了靜態內部類,因而不能訪問外部類的非靜態成員
    public static class InnerClassA {
		private int x2 = 0;
    }
}

同樣的編譯步驟,得到了兩個 .class 檔案,這裡看一下內部類的 .class 檔案反編譯的位元組碼 InnerClassTest$InnerClassA
這裡寫圖片描述
仔細看一下,確實沒有找到指向外部類物件的引用,編譯器只為這個靜態內部類提供了一個無參構造方法。
而且因為外部類物件需要訪問當前類的私有成員,編譯器給這個靜態內部類生成了一個名為 access$000 的靜態方法,作用已不用我多說了。如果我們不看類名,這個類完全可以作為一個普通的外部類來看,這正是靜態內部類和其餘的內部類的區別所在:靜態內部類物件不依賴其外部類物件存在,而其餘的內部類物件必須依賴其外部類物件而存在

OK,到這裡問題都得到了解釋:在非靜態內部類訪問外部類私有成員 / 外部類訪問內部類私有成員 的時候,對應的外部類 / 外部類會生成一個靜態方法,用來返回對應私有成員的值,而對應外部類物件 / 內部類物件通過呼叫其內部類 / 外部類提供的靜態方法來獲取對應的私有成員的值。

內部類和多重繼承

我們已經知道,Java 中的類不允許多重繼承,也就是說 Java 中的類只能有一個直接父類,而 Java 本身提供了內部類的機制,這是否可以在一定程度上彌補 Java 不允許多重繼承的缺陷呢?我們這樣來思考這個問題:假設我們有三個基類分別為 ABC,我們希望有一個類 D 達成這樣的功能:通過這個 D 類的物件,可以同時產生 ABC 類的物件,通過剛剛的內部類的介紹,我們也應該想到了怎麼完成這個需求了,建立一個類 D.java

class A {}

class B {}

class C {}

public class D extends A {
	
	// 內部類,繼承 B 類
	class InnerClassB extends B {
	}
	
	// 內部類,繼承 C 類
	class InnerClassC extends C {
	}

	// 生成一個 B 類物件
	public B makeB() {
		return new InnerClassB();
	}

	// 生成一個 C 類物件
	public C makeC() {
		return new InnerClassC();
	}
	
	public static void testA(A a) {
	    // ...
	}
	
	public static void testB(B b) {
	    // ...
	}
	
	public static void testC(C c) {
	    // ...
	}

	public static void main(String[] args) {
		D d = new D();
		testA(d);
		testB(d.makeB());
		testC(d.makeC());
	}
}

程式正確執行。而且因為普通內部類可以訪問外部類的所有成員並且外部類也可以訪問普通內部類的所有成員,因此這種方式在某種程度上可以說是 Java 多重繼承的一種實現機制。但是這種方法也是有一定代價的,首先這種結構在一定程度上破壞了類結構,一般來說,建議一個 .java 檔案只包含一個類,除非兩個類之間有非常明確的依賴關係(比如說某種汽車和其專用型號的輪子),或者說一個類本來就是為了輔助另一個類而存在的(比如說上篇文章介紹的 HashMap 類和其內部用於遍歷其元素的 HashIterator 類),那麼這個時候使用內部類會有較好程式碼結構和實現效果。而在其他情況,將類分開寫會有較好的程式碼可讀性和程式碼維護性。

內部類和記憶體洩露

在這一小節開始前介紹一下什麼是記憶體洩露:即指在記憶體中存在一些其記憶體空間可以被回收的物件因為某些原因又沒有被回收,因此產生了記憶體洩露,如果應用程式頻繁發生記憶體洩露可能會產生很嚴重的後果(記憶體中可用的空間不足導致程式崩潰,甚至導致整個系統卡死)。
聽起來怪嚇人的,這個問題在一些需要開發者手動申請和釋放記憶體的程式語言(C/C++)中會比較容易產生,因為開發者申請的記憶體需要手動釋放,如果忘記了就會導致記憶體洩露,舉個簡單的例子(C++):

#include <iostream>

int main() {
	// 申請一段記憶體,空間為 100 個 int 元素所佔的位元組數
	int *p = new int[100];
	// C++ 11
	p = nullptr;
	return 0;
}

在這段程式碼裡我有意而為之:在為指標 p 申請完記憶體之後將其直接賦值為 nullptr ,這是 C++ 11 中一個表示空指標的關鍵字,我們平時常用的 NULL 只是一個值為 0 的常量值,在進行方法過載傳參的時候可能會引起混淆。之後我直接返回了,雖然在程式結束之後作業系統會回收我們程式中申請的記憶體,但是不可否認的是上面的程式碼確實產生了記憶體洩露(申請的 100 個 int 元素所佔的記憶體無法被回收)。這只是一個最簡單不過的例子。我們在寫這類程式的時候當動態申請的記憶體不再使用時,應該要主動釋放申請的記憶體:

#include <iostream>

int main() {
	// 申請一段記憶體,空間為 100 個 int 元素所佔的位元組數
	int *p = new int[100];
	// 釋放 p 指標所指向的記憶體空間
	delete[] p;
	// C++ 11
	p = nullptr;
	return 0;
}

而在 Java 中,因為 JVM 有垃圾回收功能,對於我們自己建立的物件無需手動回收這些物件的記憶體空間,這種機制確實在一定程度上減輕了開發者的負擔,但是也增加了開發者對 JVM 垃圾回收機制的依賴性,從某個方面來說,也是弱化了開發者防止記