Java 干貨之深入理解Java內部類


可以將一個類定義在另一個類或方法中,這樣的類叫做內部類 --《Thinking in Java》

說起內部類,大家並不陌生,並且會經常在實例化容器的時候使用到它。但是內部類的具體細節語法,原理以及實現是什么樣的可以不少人都還挺陌生,這里作一篇總結,希望通過這篇總結提高對內部類的認識。


內部類是什么?

由文章開頭可知,內部類的定義為:定義在另一個類或方法中的類。而根據使用場景的不同,內部類還可以分為四種:成員內部類,局部內部類,匿名內部類和靜態內部類。每一種的特性和注意事項都不同,下面我們一一說明。

成員內部類

顧名思義,成員內部類是定義在類內部,作為類的成員的類。如下:

public class Outer {
    
   public class Inner{
       
   }

}

特點如下:

  1. 成員內部類可以被權限修飾符(eg. public,private等)所修飾
  2. 成員內部類可以訪問外部類的所有成員,(包括private)成員
  3. 成員內部類是默認包含了一個指向外部類對象的引用
  4. 如同使用this一樣,當成員名或方法名發生覆蓋時,可以使用外部類的名字加.this指定訪問外部類成員。如:Outer.this.name
  5. 成員內部類不可以定義static成員
  6. 成員內部類創建語法:
Outer outer=new Outer();
Outer.Inner inner=outer.new Inner();

局部內部類

局部內部類是定義在方法或者作用域中類,它和成員內部類的區別僅在於訪問權限的不同。

public class Outer{
    public void test(){
        class Inner{
            
        }
    }
}

特點如下:

  1. 局部內部類不能有訪問權限修飾符

  2. 局部內部類不能被定義為static

  3. 局部內部類不能定義static成員

  4. 局部內部類默認包含了外部類對象的引用

  5. 局部內部類也可以使用Outer.this語法制定訪問外部類成員

  6. 局部內部類想要使用方法或域中的變量,該變量必須是final

    在JDK1.8 以后,沒有final修飾,effectively final的即可。什么意思呢?就是沒有final修飾,但是如果加上final編譯器也不會報錯即可。

匿名內部類

匿名內部類是與繼承合並在一起的沒有名字的內部類

public class Outer{
    public List<String> list=new ArrayList<String>(){
        {
            add("test");
        }
    };
}

這是我們平時最常用的語法。
匿名內部類的特點如下:

  1. 匿名內部類使用單獨的塊表示初始化塊{}
  2. 匿名內部類想要使用方法或域中的變量,該變量必須是final修飾的,JDK1.8之后effectively final也可以
  3. 匿名內部類默認包含了外部類對象的引用
  4. 匿名內部類表示繼承所依賴的類

嵌套類

嵌套類是用static修飾的成員內部類

public class Outer {
    
   public static class Inner{
       
   }

}

特點如下:

  1. 嵌套類是四種類中唯一一個不包含對外部類對象的引用的內部類

  2. 嵌套類可以定義static成員

  3. 嵌套類能訪問外部類任何靜態數據成員與方法。

    構造函數可以看作靜態方法,因此可以訪問。


為什么要有內部類?

從上面可以看出,內部類的特性和類方差不多,但是內部類有許多繁瑣的細節語法。既然內部類有這么多的細節要注意,那為什么Java還要支持內部類呢?

1. 完善多重繼承
  1. 在早期C++作為面向對象編程語言的時候,最難處理的也就是多重繼承,多重繼承對於代碼耦合度,代碼使用人員的理解來說,並不怎么友好,並且還要比較出名的死亡菱形的多重繼承問題。因此Java並不支持多繼承。
  2. 后來,Java設計者發現,沒有多繼承,一些代碼友好的設計與編程問題變得十分難以解決。於是便產生了內部類。內部類具有:隱式包含外部類對象並且能夠與之通信的特點,完美的解決了多重繼承的問題。
2. 解決多次實現/繼承問題
  1. 有時候在一個類中,需要多次通過不同的方式實現同一個接口,如果沒有內部類,必須多次定義不同數量的類,但是使用內部類可以很好的解決這個問題,每個內部類都可以實現同一個接口,即實現了代碼的封裝,又實現了同一接口不同的實現。

  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.classOuter$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修飾的。

因此:

  1. 成員內部類作為類級成員,因此能被訪問修飾符所修飾
  2. 成員內部類中包含創建內部類時對外部類對象的引用,所以成員內部類能訪問外部類的所有成員。
  3. 語法規定:因為它作為外部類的一部分成員,所以即使private的對象,內部類也能訪問。。通過Outer.access$ 指令訪問
  4. 如同非靜態方法不能訪問靜態成員一樣,非靜態內部類也被設計的不能擁有靜態變量,因此內部類不能定義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.classOuter$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后,程序不報錯。

  1. 局部內部類還有個特點就是不能有權限修飾符。就好像局部變量不能有訪問修飾符一樣

  2. 由上面可以看到,外部對象同樣是被傳入局部類中,因此局部類可以訪問外部對象

嵌套類——>靜態方法

嵌套類沒什么好說的,就好像靜態方法一樣,他可以被直接訪問,他也能定義靜態變量。同時不能訪問非靜態成員。
值得注意的是《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可以防止內部類內存泄露。

尊重勞動成果,轉載請標注出處。


如果覺得寫得不錯,歡迎關注微信公眾號:逸游Java ,每天不定時發布一些有關Java干貨的文章,感謝關注

參考文章:
Java 中引入內部類的意義?
成員內部類里面為什么不能有靜態成員和方法?


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM