Java 乾貨之深入理解Java內部類
可以將一個類定義在另一個類或方法中,這樣的類叫做內部類 --《Thinking in Java》
說起內部類,大家並不陌生,並且會經常在例項化容器的時候使用到它。但是內部類的具體細節語法,原理以及實現是什麼樣的可以不少人都還挺陌生,這裡作一篇總結,希望通過這篇總結提高對內部類的認識。
內部類是什麼?
由文章開頭可知,內部類的定義為:定義在另一個類或方法中的類。而根據使用場景的不同,內部類還可以分為四種:成員內部類,區域性內部類,匿名內部類和靜態內部類。每一種的特性和注意事項都不同,下面我們一一說明。
成員內部類
顧名思義,成員內部類是定義在類內部,作為類的成員的類。如下:
public class Outer { public class Inner{ } }
特點如下:
- 成員內部類可以被許可權修飾符(eg.
public,private等
)所修飾 - 成員內部類可以訪問外部類的所有成員,(包括
private
)成員 - 成員內部類是預設包含了一個指向外部類物件的引用
- 如同使用
this
一樣,當成員名或方法名發生覆蓋時,可以使用外部類的名字加.this指定訪問外部類成員。如:Outer.this.name
- 成員內部類不可以定義
static
成員 - 成員內部類建立語法:
Outer outer=new Outer();
Outer.Inner inner=outer.new Inner();
區域性內部類
區域性內部類是定義在方法或者作用域中類,它和成員內部類的區別僅在於訪問許可權的不同。
public class Outer{
public void test(){
class Inner{
}
}
}
特點如下:
- 區域性內部類不能有訪問許可權修飾符
- 區域性內部類不能被定義為
static
- 區域性內部類不能定義
static
成員 - 區域性內部類預設包含了外部類物件的引用
- 區域性內部類也可以使用
Outer.this
語法制定訪問外部類成員 區域性內部類想要使用方法或域中的變數,該變數必須是
final
的在JDK1.8 以後,沒有
final
修飾,effectively final
的即可。什麼意思呢?就是沒有final
final
編譯器也不會報錯即可。
匿名內部類
匿名內部類是與繼承合併在一起的沒有名字的內部類
public class Outer{
public List<String> list=new ArrayList<String>(){
{
add("test");
}
};
}
這是我們平時最常用的語法。 匿名內部類的特點如下:
- 匿名內部類使用單獨的塊表示初始化塊
{}
- 匿名內部類想要使用方法或域中的變數,該變數必須是
final
修飾的,JDK1.8之後effectively final
也可以 - 匿名內部類預設包含了外部類物件的引用
- 匿名內部類表示繼承所依賴的類
巢狀類
巢狀類是用static
修飾的成員內部類
public class Outer {
public static class Inner{
}
}
特點如下:
- 巢狀類是四種類中唯一一個不包含對外部類物件的引用的內部類
- 巢狀類可以定義
static
成員 巢狀類能訪問外部類任何靜態資料成員與方法。
建構函式可以看作靜態方法,因此可以訪問。
為什麼要有內部類?
從上面可以看出,內部類的特性和類方差不多,但是內部類有許多繁瑣的細節語法。既然內部類有這麼多的細節要注意,那為什麼Java還要支援內部類呢?
1. 完善多重繼承
- 在早期C++作為面向物件程式語言的時候,最難處理的也就是多重繼承,多重繼承對於程式碼耦合度,程式碼使用人員的理解來說,並不怎麼友好,並且還要比較出名的死亡菱形的多重繼承問題。因此Java並不支援多繼承。
- 後來,Java設計者發現,沒有多繼承,一些程式碼友好的設計與程式設計問題變得十分難以解決。於是便產生了內部類。內部類具有:隱式包含外部類物件並且能夠與之通訊的特點,完美的解決了多重繼承的問題。
2. 解決多次實現/繼承問題
有時候在一個類中,需要多次通過不同的方式實現同一個介面,如果沒有內部類,必須多次定義不同數量的類,但是使用內部類可以很好的解決這個問題,每個內部類都可以實現同一個介面,即實現了程式碼的封裝,又實現了同一介面不同的實現。
內部類可以將組合的實現封裝在內部中。
為什麼內部類的語法這麼繁雜
這一點是本文的重點。內部類語法之所以這麼繁雜,是因為它是新資料型別加語法糖的結合。想要理解內部類,還得從本質上出發.
內部類根據應用場景的不同分為4種。其應用場景完全可以和類方法對比起來。 下面我們通過類方法對比的模式一一解答為什麼內部類會有這樣的特點
成員內部類——>成員方法
成員內部類的設計完全和成員方法一樣。
呼叫成員方法:outer.getName()
新建內部類物件:outer.new Inner()
它們都是要依賴物件而被呼叫。
正如《Thinking in Java》所說,outer.getName()
正真的形似是Outer.getName(outer)
,也就是將呼叫物件作為引數傳遞給方法。
新建一個內部類也是這樣:Outer.new Inner(outer)
下面,我們用實際情況證明: 新建一個包含內部類的類:
public class Outer {
private int m = 1;
public class Inner {
private void test() {
//訪問外部類private成員
System.out.println(m);
}
}
}
編譯,會發現會在編譯目標目錄生成兩個.class檔案:Outer.class
和Outer$Inner.class
。
PS:不知道為什麼Java總是和$過不去,就連變數命名規則都要比C++多一個能由$組成 :)
將Outer$Inner.class
放入IDEA中開啟,會自動反編譯,檢視結果:
public class Outer$Inner {
public Outer$Inner(Outer this$0) {
this.this$0 = this$0;
}
private void test() {
System.out.println(Outer.access$000(this.this$0));
}
}
可以看見,編譯器已經自動生成了一個預設構造器,這個預設構造器是一個帶有外部型別引用的引數構造器。
可以看到外部類成員物件的引用:Outer是由final
修飾的。
因此:
- 成員內部類作為類級成員,因此能被訪問修飾符所修飾
- 成員內部類中包含建立內部類時對外部類物件的引用,所以成員內部類能訪問外部類的所有成員。
- 語法規定:因為它作為外部類的一部分成員,所以即使
private
的物件,內部類也能訪問。。通過Outer.access$ 指令訪問 - 如同非靜態方法不能訪問靜態成員一樣,非靜態內部類也被設計的不能擁有靜態變數,因此內部類不能定義
static
物件和方法。
但是可以定義
static final
變數,這並不衝突,因為所定義的final
欄位必須是編譯時確定的,而且在編譯類時會將對應的變數替換為具體的值,所以在JVM看來,並沒有訪問內部類。
區域性內部類——> 區域性程式碼塊
區域性內部類可以和區域性程式碼塊相理解。它最大的特點就是隻能訪問外部的final
變數。
先彆著急問為什麼。
定義一個區域性內部類:
public class Outer {
private void test() {
int m= 3;
class Inner {
private void print() {
System.out.println(m);
}
}
}
}
編譯,發現生成兩個.class檔案Outer.class
和Outer$1Inner.class
將Outer$1Inner.class
放入IDEA中反編譯:
class Outer$1Inner {
Outer$1Inner(Outer this$0, int var2) {
this.this$0 = this$0;
this.val$m = var2;
}
private void print() {
System.out.println(this.val$m);
}
}
可以看見,編譯器自動生成了帶有兩個引數的預設構造器。 看到這裡,也許應該能明瞭:我們將程式碼轉換下:
public class Outer {
private void test() {
int m= 3;
Inner inner=new Outer$1Inner(this,m);
inner.print();
}
}
}
也就是在Inner中,其實是將m的值,拷貝到內部類中的。print()
方法只是輸出了m,如果我們寫出了這樣的程式碼:
private void test() {
int m= 3;
class Inner {
private void print() {
m=4;
}
}
System.out.println(m);
}
在我們看來,m的值應該被修改為4,但是它真正的效果是:
private void test(){
int m = 3;
print(m);
System.out.println(m);
}
private void print(int m){
m=4;
}
m被作為引數拷貝進了方法中。因此修改它的值其實沒有任何效果,所以為了不讓程式設計師隨意修改m而卻沒達到任何效果而迷惑,m必須被final
修飾。
繞了這麼大一圈,為什麼編譯器要生成這樣的效果呢?
其實,瞭解閉包的概念的人應該都知道原因。而Java中各種詭異的語法一般都是由生命週期帶來的影響。上面的程式中,m是一個區域性變數,它被定義在棧上,而new Outer$1Inner(this,m);
所生成的物件,是定義在堆上的。如果不將m作為成員變數拷貝進物件中,那麼離開m的作用域,Inner
物件所指向的便是一個無效的地址。因此,編譯器會自動將區域性類所使用的所有引數自動生成成員。
為什麼其他語言沒有這種現象呢? 這又回到了一個經典的問題上:Java是值傳遞還是引用傳遞。由於Java always pass-by-value,對於真正的引用,Java是無法傳遞過去的。而上面的問題核心就在與m如果被改變了,那麼其它的m的副本是無法感知到的。而其他語言都通過其他的途徑解決了這個問題。 對於C++就是一個指標問題。
理解了真正的原因,便也能知道什麼時候需要final
,什麼時候不需要final
了。
public class Outer {
private void test() {
class Inner {
int m=3;
private void print() {
System.out.println(m);//作為引數傳遞,本身都已經 pass-by-value。不用final
int c=m+1; //直接使用m,需要加final
}
}
}
}
而在Java 8 中,已經放寬政策,允許是effectively final
的變數,實際上,就是編譯器在編譯的過程中,幫你加上final
而已。而你應該保證允許編譯器加上final
後,程式不報錯。
區域性內部類還有個特點就是不能有許可權修飾符。就好像區域性變數不能有訪問修飾符一樣
由上面可以看到,外部物件同樣是被傳入區域性類中,因此區域性類可以訪問外部物件
巢狀類——>靜態方法
巢狀類沒什麼好說的,就好像靜態方法一樣,他可以被直接訪問,他也能定義靜態變數。同時不能訪問非靜態成員。 值得注意的是《Think in Java》中說過,可以將建構函式看作為靜態方法,因此巢狀類可以訪問外部類的構造方法。
匿名類——>區域性方法+繼承的語法糖
匿名類可以看作是對前3種類的再次擴充套件。具體來說匿名類根據應用場景可以看作:
- 成員內部類+繼承
- 區域性內部類+繼承
- 巢狀內部類+繼承
匿名類語法為:
new 繼承類名(){
//Override 過載的方法
}
返回的結果會向上轉型為繼承類。
宣告一個匿名類:
public class Outer {
private List<String> list=new ArrayList<String>(){
{
add("test");
}
};
}
這便是一個經典的匿名類用法。
同樣編譯上面程式碼會看到生成了兩個.class檔案Outer.class
,Outer$1.class
將Outer$1.class
放入IDEA中反編譯:
class Outer$1 extends ArrayList<String> {
Outer$1(Outer this$0) {
this.this$0 = this$0;
this.add("1");
}
}
可以看到匿名類的完整語法便是繼承+內部類。
由於匿名類可以申明為成員變數,區域性變數,靜態成員變數,因此它的組合便是幾種內部類加繼承的語法糖,這裡不一一證明。
在這裡值得注意的是匿名類由於沒有類名,因此不能通過語法糖像正常的類一樣宣告建構函式,但是編譯器可以識別{}
,並在編譯的時候將程式碼放入建構函式中。
{}
可以有多個,會在生成的建構函式中按順序執行。
怎麼正確的使用內部類
在第二小節中,我們已經討論過內部類的應用場景,但是如何優雅,並在正確的應用場景使用它呢?本小節將會詳細討論。
1.注意記憶體洩露
《Effective Java》第二十四小節明確提出過。優先使用靜態內部類。這是為什麼呢? 由上面的分析我們可以知道,除了巢狀類,其他的內部類都隱式包含了外部類物件。這便是Java記憶體洩露的源頭。看程式碼:
定義Outer:
public class Outer{
public List<String> getList(String item) {
return new ArrayList<String>() {
{
add(item);
}
};
}
}
使用Outer:
public class Test{
public static List<String> getOutersList(){
Outer outer=new Outer();
//do something
List<String> list=outer.getList("test");
return list;
}
public static void main(String[] args){
List<String> list=getOutersList();
//do something with list
}
}
相信這樣的程式碼一定有同學寫出來,這涉及到一個習慣的問題:
不涉及到類成員方法和成員變數的方法,最好定義為static
我們先研究上面的程式碼,最大的問題便是帶來的記憶體洩露:
在使用過程中,我們定義Outer
物件完成一系列的動作
- 使用
outer
得到了一個ArraList
物件 - 將
ArrayList
作為結果返回出去。
正常來說,在getOutersList
方法中,我們new
出來了兩個物件:outer
和list
,而在離開此方法時,我們只將list
物件的引用傳遞出去,outer
的引用隨著方法棧的退出而被銷燬。按道理來說,outer
物件此時應該沒有作用了,也應該在下一次記憶體回收中被銷燬。
然而,事實並不是這樣。按上面所說的,新建的list
物件是預設包含對outer
物件的引用的,因此只要list
不被銷燬,outer
物件將會一直存在,然而我們並不需要outer
物件,這便是記憶體洩露。
怎麼避免這種情況呢?
很簡單:不涉及到類成員方法和成員變數的方法,最好定義為static
public class Outer{
public static List<String> getList(String item) {
return new ArrayList<String>() {
{
add(item);
}
};
}
}
這樣定義出來的類便是巢狀類+繼承,並不包含對外部類的引用。
2.應用於只實現一個介面的實現類
- 優雅工廠方法模式
我們可以看到,在工廠方法模式中,每個實現都會需要實現一個Fractory來實現產生物件的介面,而這樣介面其實和原本的類關聯性很大的,因此我們可以將Fractory定義在具體的類中,作為內部類存在
- 簡單的實現介面
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("test");
}
}
).start();
}
儘量不要直接使用Thread,這裡只做演示使用 Java 8 的話建議使用lambda代替此類應用
- 同時實現多個介面
public class imple{
public static Eat getDogEat(){
return new EatDog();
}
public static Eat getCatEat(){
return new EatCat();
}
private static class EatDog implements Eat {
@Override
public void eat() {
System.out.println("dog eat");
}
}
private static class EatCat implements Eat{
@Override
public void eat() {
System.out.println("cat eat");
}
}
}
3.優雅的單例類
public class Imple {
public static Imple getInstance(){
return ImpleHolder.INSTANCE;
}
private static class ImpleHolder{
private static final Imple INSTANCE=new Imple();
}
}
4.反序列化JSON接受的JavaBean 有時候需要反序列化巢狀JSON
{
"student":{
"name":"",
"age":""
}
}
類似這種。我們可以直接定義巢狀類進行反序列化
public JsonStr{
private Student student;
public static Student{
private String name;
private String age;
//getter & setter
}
//getter & setter
}
但是注意,這裡應該使用巢狀類,因為我們不需要和外部類進行資料交換。
核心思想:
- 巢狀類能夠訪問外部類的建構函式
- 將第一次訪問內部類放在方法中,這樣只有呼叫這個方法的時候才會第一次訪問內部類,實現了懶載入
內部類還有很多用法,這裡不一一列舉。
總結
內部類的理解可以按照方法來理解,但是內部類很多特性都必須剝開語法糖和明白為什麼需要這麼做才能完全理解,明白內部類的所有特性才能更好使用內部類,在內部類的使用過程中,一定記住:能使用巢狀類就使用巢狀類,如果內部類需要和外部類聯絡,才使用內部類。最後不涉及到類成員方法和成員變數的方法,最好定義為static可以防止內部類記憶體洩露。
尊重勞動成果,轉載請標註出處。