Java 內部類與閉包

不洗碗工作室發表於2019-02-21

作者:不洗碗工作室 – Marklux

出處:Marklux`s Pub

版權歸作者所有,轉載請註明出處

Java內部類

基本定義

很簡單,無非是在類的內部再定義一個類,這被稱為成員內部類:

public class OuterClass {
    private String name;
    private int age;
    class InnerClass {
        public InnerClass(){
            name = "mark";
            age = 20;
        }
        public void echo() {
            System.out.println(name + " " + age);
        }
    }
}
複製程式碼

問題思考

上面這個很簡單的例子中,也包含了很多應該思考的問題:

  • 內部類如何被例項化?
  • 內部類能否改變外圍類的屬性,兩者之間又是什麼一種關係?
  • 內部類存在的意義是什麼?

在回答這三個問題之前,必須要明確一個點,那就是內部類是依附於外圍類而存在的,其實也就是內部類存在著指向外圍類的引用。明白了這個之後,上面的問題就好解答了。

例項化與資料訪問

內部類與外圍類之間形成了一種聯絡,使得內部類可以無限制地訪問外圍類中的任意屬性。
正如上面的例子中,InnerClass內部可以隨意訪問OuterClass中的private屬性。

同樣的,因為內部類依賴與外圍類的存在,所以無法在外部直接將其例項化,而是必須先例項化外圍類,才能夠例項化內部類(注意,在外圍類的成員方法裡仍然是可以直接例項化內部類的):

public static void main(String[] args) {
        InnerClass inner = new OuterClass().new InnerClass();
        inner.echo();
}
複製程式碼

使用外圍類的.new來建立外部類。

我們也知道,內部類和外圍類的聯絡是通過內部類所持有的外部類的引用來實現的,想要獲取這個引用,可以使用外圍類的.this來實現,可以參考下面這個測試用例

public class OuterClass {
    private String name;
    private int age;
    class InnerClass {
        public InnerClass(){
            name = "mark";
            age = 20;
        }
        public void echo() {
            System.out.println(name + " " + age);
        }
        public OuterClass getOuter() {
            return OuterClass.this;
        }
    }

    @Test
    public void test() {
        OuterClass outer = new OuterClass();
        InnerClass inner = outer.new InnerClass();
        Assert.assertEquals(outer, inner.getOuter());
    }
}
複製程式碼

內部類的作用

內部類建立起來很麻煩,使用起來也令人困擾,那麼內部類存在的意義是什麼呢?

實現多重繼承

這可能是內部類存在的最重要的意義,參考《Thinking in Java》中的解釋:

使用內部類最吸引人的原因是:每個內部類都能獨立地繼承一個(介面的)實現,所以無論外圍類是否已經繼承了某個(介面的)實現,對於內部類都沒有影響。

我們都知道,Java中取消了C++中類的多重繼承(但是允許介面的多重實現),但是在實際程式設計中,又不免會遇到同一個類需要同時繼承自兩個類的情況,這時候就可以使用內部類來實現了。

比如有兩個抽象類

public abstract class AbstractFather {
    protected int number;
    protected String fatherName;

    public abstract String sayHello();
}

public abstract class AbstractMother {
    protected int number;
    protected String motherName;

    public abstract String sayHello();
}
複製程式碼

如果想要同時繼承這兩個類,勢必會引起number變數的衝突,以及sayHello方法的衝突,這些問題在C++中是以一種複雜的方案來實現的,如果使用內部類,就可以使用兩個不同的類來繼承不同的基類,並且可以根據自己的需要來組織資料的訪問:

public class TestClass extends AbstractFather {
    @Override
    public String sayHello() {
        return fatherName;
    }

    class TestInnerClass extends AbstractMother {
        @Override
        public String sayHello() {
            return motherName;
        }
    }
}
複製程式碼

其他

(摘自《Think in Java》)

  1. 內部類可以用多個例項,每個例項都有自己的狀態資訊,並且與其他外圍物件的資訊相互獨立。

  2. 在單個外圍類中,可以讓多個內部類以不同的方式實現同一個介面,或者繼承同一個類。

  3. 建立內部類物件的時刻並不依賴於外圍類物件的建立。

  4. 內部類並沒有令人迷惑的“is-a”關係,他就是一個獨立的實體。

  5. 內部類提供了更好的封裝,除了該外圍類,其他類都不能訪問。

內部類的分類

上面的例子中建立的內部類,都屬於成員內部類,實際上Java中還有三種其他的內部類:

  1. 區域性內部類

    巢狀在方法裡或者是某個作用域內,通常情況下不希望這個類是公共可用的,相比於成員內部類,區域性內部類的作用域更加狹小了,出了方法或者作用域就無法被訪問。一般用於在內部實現一些私有的輔助功能。

    定義在方法裡:

    public class Parcel5 {
    public Destionation destionation(String str){
        class PDestionation implements Destionation{
            private String label;
            private PDestionation(String whereTo){
                label = whereTo;
            }
            public String readLabel(){
                return label;
            }
        }
        return new PDestionation(str);
    }
    
    public static void main(String[] args) {
        Parcel5 parcel5 = new Parcel5();
        Destionation d = parcel5.destionation("chenssy");
    }
    }
    複製程式碼

    定義在作用域內:

    public class Parcel6 {
    private void internalTracking(boolean b){
        if(b){
            class TrackingSlip{
                private String id;
                TrackingSlip(String s) {
                    id = s;
                }
                String getSlip(){
                    return id;
                }
            }
            TrackingSlip ts = new TrackingSlip("chenssy");
            String string = ts.getSlip();
        }
    }
    
    public void track(){
        internalTracking(true);
    }
    
    public static void main(String[] args) {
        Parcel6 parcel6 = new Parcel6();
        parcel6.track();
    }
    複製程式碼

}
“`

  1. 靜態內部類

    使用了static修飾的內部類即為靜態內部類,和普通的成員內部類最大的不同是,靜態內部類沒有了指向外圍類的引用。
    因此,它的建立不需要依賴於外圍類,但也不能夠使用任何外圍類的非static成員變數和方法。

    靜態內部類一個很好的用途是,用來建立執行緒安全的單例模式:

    public class Singleton {  
        private static class SingletonHolder {  
            private static final Singleton INSTANCE = new Singleton();  
        }  
        private Singleton (){}  
        public static final Singleton getInstance() {  
            return SingletonHolder.INSTANCE; 
        }  
    }
    複製程式碼

    這是利用了JVM的特性:靜態內部類時在類載入時實現的,因此不會受到多執行緒的影響,自然也就不會出現多個例項。

  2. 匿名內部類

    匿名內部類就是沒有被命名的內部類,當我們需要快速建立多個Thread的時候,經常會使用到它:

    new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello");
            }
        }).start();
    複製程式碼

    當然現在也可以用λ表示式來實現了,我們暫且不提這兩者之間的關係,先來看一下使用匿名內部類時要注意哪些事情:

    • 匿名內部類沒有訪問修飾符,也沒有構造方法
    • 匿名內部類依附於介面而存在,如果它要繼承的介面並不存在,那這個類就無法被建立
    • 如果匿名內部類要訪問區域性變數,那這個資料必須是final的

    熟悉函數語言程式設計的人會發現匿名內部類和函式閉包有些類似(這也是為什麼能夠用λ表示式來代替它),但實際上兩者還是有著一些區別的,下面的部分中我們就來對比閉包和內部類。

Java內部類與閉包

何為閉包?

在之前的淺談閉包一文中已經闡述過了,用一句話來形容閉包就是:

閉包是由函式及其相關的引用環境組合而成的實體(即:閉包=函式+引用環境)

仍然用Go語言寫一個非常簡單的閉包以便後面和Java匿名內部類進行對比:

func Add(y int) {
	return func(x int) int {
		return x + y
	}
}

a := Add(10)
a(5) // return 15
複製程式碼

我們知道這個返回的閉包中包含了Add函式中提供的變數y(也就是閉包產生的環境),現在我們來思考一下閉包究竟是如何實現的。

根據定義,閉包 = 函式 + 引用環境, 在上述的例子中,引用環境就是區域性變數y,那麼可不可以用一個結構體來定義這個閉包呢(注:Golang中的結構體和Java中的類地位相同),答案是可以的(實際上Golang底層也是這樣定義閉包的):

type Closure struct {
	F func(x int) int
	y *int
}
複製程式碼

也就是說這個閉包包含了函式本身,以及一個對區域性變數y的引用。

這裡特別需要注意的一點是,如果y是定義在函式Add的呼叫棧裡的一個變數,那麼當Add()函式被呼叫完畢後,y就銷燬了,這時候再用原來的指標去訪問y就會出問題,因此這裡就出現了一個原則:

閉包中引用的外部變數必須是在堆上分配的

實際上Go的編譯器在處理到這個閉包時,會使用escape analyze來識別變數y的作用域,當發現變數y被一個閉包所引用時,就會把y轉移到堆中(這一過程稱為變數逃逸)。

總結一下,閉包從底層理解,就是函式本身和其所需外部變數的引用,用R大的話來形容閉包的建立過程就是:

capture by reference

內部類與閉包

看一下最後我們對閉包的定義,“所需外部變數的引用”是否像極了Java中內部類對外圍類的引用?

所以大家都認為Java中不存在閉包,其實Java裡處處都是閉包(物件就是閉包),以致於我們感覺不到自己在使用閉包,成員內部類就是一個最典型的例子,因為它持有一個指向外圍類的引用,看下面這個例子:

public class OuterClass {
    private int y = 10;
    private class Inner {
        public int innerAdd(int x) {
            return x + y;
        }
    }

    public static void main(String[] args) {
        OuterClass outer = new OuterClass();
        Inner inner = outer.new Inner();
        System.out.println(inner.innerAdd(5)); //result: 15
    }
}
複製程式碼

這樣的用法,和之前在Go語言裡演示的那個閉包用起來其實別無二致,只是在Go語言裡寫起來要簡潔許多(畢竟天生支援函數語言程式設計)。

匿名內部類是閉包嗎?

剛才在講到匿名內部類時,也提到了閉包,那麼匿名內部類是閉包嗎?

先來嘗試用匿名內部類建立一個類似閉包的結構吧:

interface AnnoInner {
    int innerAdd(int x);
}

public class OuterClass {
    private int y = 100;
    public AnnoInner getAnnoInner() {
        int z = 10;
        return new AnnoInner() {
            @Override
            public int innerAdd(int x) {
                // z = 20; 報錯
                return x + y + z;
            }
        };
    }

    public static void main(String[] args) {
        OuterClass outer = new OuterClass();
        AnnoInner inner = outer.getAnnoInner();
        System.out.println(inner.innerAdd(5)); //result: 115
    }
}
複製程式碼

我們可以看到,匿名內部類也能正常的引用外圍類的成員變數y,但是對於區域性變數z,如果嘗試在匿名內部類裡改變它的值(讀取沒問題),編譯器就會報錯。

這也對應了上面介紹匿名內部類的時候引入的規則:

如果匿名內部類要訪問區域性變數,那個變數必須是final的

之所以會形成這樣的結果,是由於Java在實現匿名內部類對外部區域性變數訪問時,並不是像Go的編譯器那樣將區域性變數提升到堆中,然後傳遞引用來實現;而是對區域性變數建立值拷貝,然後供匿名內部類來使用。

所以上面的例子在Java編譯器的實現可以簡單的理解成這樣:

return new AnnoInner() {
            @Override
            public int innerAdd(int x) {
                int copyZ = z; // 建立z的值拷貝
                return x + y + copyZ;
            }
        };
複製程式碼

所以說,Java中的匿名內部類是一個不完整的閉包,用R大的話說就是:

capture by value, 而不是 capture by reference

參考閱讀:

相關文章