天天看點

java8 閉包_從 λ 演算看 JS 與 JAVA8 閉包

java8 閉包_從 λ 演算看 JS 與 JAVA8 閉包

關于 λ 演算在這篇部落格 λ表達式與邱奇數,JAVA lamda表達式實作 中做了一個初步的介紹,這次我們來看一些實際應用中的例子:閉包。閉包的知識點有很多,但核心概念就一個,從 λ 演算的角度看便是:自由變量的替換依賴于定義函數的上下文環境。也就是說上下文環境的改變會通過影響函數中的自由變量而直接影響函數的定義。

在 js 中閉包的使用非常多,js 的閉包基于函數作用域鍊,可被用來定義命名空間及進行變量逃逸,是 js 子產品化的基礎。但在 java 中用的相對較少,因為 java 的文法限制,從 λ 演算的角度看,java為了語言的簡潔和正确性,禁止了我們對函數中自由變量的修改。這便是 java 與 js 閉包不同的核心。在 java 看來,因為本身已經有非常完備的類型支援,不需要借助閉包來定義命名空間。修改自由變量(即在lambda函數之外定義的任何對象)的Lambda函數可能會産生混淆。其他功能的副作用可能會導緻不必要的錯誤。

我們首先來看一下自由變量,對于一個 λ 表達式:

λx.x+1

等價于:

f(x)=x+1

在上述函數中,x為入參,被 λ 綁定,是以 x 是一個綁定變量,這是相對于自由變量的一個概念。而對于如下表達式:

λx.x+y

等價于:

f(x)=x+y

其中 x 為入參,被 λ 綁定。而 y 并沒有被綁定,y 便是該表達式中的自由變量。

在實際的生産中,純粹的自由變量是不存在的,因為如果一個變量始終都不會被指派,那麼該變量對于函數的運作将毫無意義。通常一個 λ 表達式中的自由變量會被更外層的 λ 表達式進行 λ 綁定。也就是說 λx.x+y 的外層通常會有一個 λy 對該表達式進行了綁定(不是兩個入參的函數的柯裡化表示,而是函數嵌套調用), 或者是在函數聲明時,y 便被替換為了實參。

對于第一種情況,外層再次綁定的結果便是函數的嵌套調用,即 λy.λx.x+y ,等價于:

f(x)=x;

f(y)=f(x)+y;

(柯裡化的情況等價于 f(x,y)=x+y)

而對于第二種情況,便是我們所說的閉包。λx.x+y 中的自由變量 y 始終未被綁定(沒有被任何一層函數調用作為入參),而是在函數聲明時,被替換成了某個具體的值。下面我們看 java 中具體的例子:

public ConsumergetCosumer(){

Integer i0=1;

Integer i1=2;

Consumer f=(inConsumer)->{

System.out.println(i0+inConsumer);

};

f.accept(i1);returnf;

}

我們在函數  getCosumer()  中又定義了另一個函數 f.accept() 。

f.accept() 的 λ 表示為 λ inConsumer.i0+inConsumer 。其中 i0 是一個自由變量,依賴外層函數 getCosumer() 中的局部變量 i0 。也就是說  f.accept()  的定義是依賴于 getCosumer()  的執行的。如果 getCosumer() 的上下文不存在,則  f.accept() 是不完整的,因為 i0 始終是一個變量無法替換為有意義的實際值。

對于 java 來說,i0 的傳遞依賴的是匿名内部類的傳參,也就是說 i0 必須是值不可變的 final 類型(代碼中沒有用 final 修飾 i0 是因為 java8 及之後的版本,編譯器會為我們自動将向匿名内部類傳遞的參數聲明為 final )。我們嘗試改變 i0 的值,在編譯期會直接報錯:

java8 閉包_從 λ 演算看 JS 與 JAVA8 閉包

而對 i1 的改變則沒有限制。 i1 是入參,供函數執行時使用,但對函數的定義沒有影響。而 i0 是函數中的自由變量,依賴其所處的運作環境,是函數的定義的一部分。

如果還覺着抽象我們再看一個例子,改一下上面的 getCosumer() 方法:

public static voidmain(String[] args){

ClosureTest c=newClosureTest();

c.getCosumer(1);

c.getCosumer(2);

}public ConsumergetCosumer(Integer para){

Integer i0=para;

Integer i1=2;

Consumer f=(inConsumer)->{

System.out.println(in+inConsumer);

};

i0=5;

f.accept(i1);returnf;

}

java8 閉包_從 λ 演算看 JS 與 JAVA8 閉包

我們執行兩次外層方法 getCosumer() ,獲得兩個上下文中的  f.accept(),對于兩個  f.accept() 來說,雖然它們的入參 i1 都是2,但因為上下文不同導緻了 i0 的不同。

可以這麼說, 兩個上下文中的  f.accept() 函數不是同一個函數,分别是 λ.inConsumer 1 + inConsumer 與 λ.inConsumer 2 + inConsumer 。自由變量的替換将直接影響函數的定義。

在這種情況下,如果修改 getCosumer() 上下文中的 i0 的值,其内部函數 f.accept() 的定義也會随着改變,是以  java  禁止我們對 i0 的值進行改變,必須對其用 final 修飾。而在向 f.accept() 方法傳遞 i0 的值時則是傳遞了一份變量的副本,而不是直接傳遞 i0 的引用。在 getCosumer() 執行完後, 棧中的 i0 随着棧幀被釋放掉,傳回的 f.accept() 中儲存了一份 i0 的副本(值)。

js 的閉包的本質與上述 java 代碼相同,但 js 允許對自由變量進行修改。我們來看一段代碼:

var fun=function(){var a='我是外部函數的變量a';return function(){console.log(a);}

}var result=fun();

result();

内部匿名方法做為傳回值,其定義依賴外部函數 fun() 中的局部變量 a 。與 java 不同的是,在 js 中函數也是對象(而 java 中依賴實作了函數接口的匿名内部類對象來定義函數對象),内部匿名方法聲明後,外部方法 fun() 的執行上下文并沒有随着 fun() 的執行結束而被銷毀。因為 fun() 将本次調用上下文中變量 a 的引用直接傳遞給了内部匿名函數(而 java 中如果傳遞給内部類的是方法中的局部變量,則隻是将變量的副本傳遞給了内部類對象)。另外,js 中允許外部方法對本層提供給内部方法的自由變量進行修改,也就是本例中修改 a 的值,而這在 java 中是不被允許的。

每次外部函數的調用都會形成一個新的作用域,在此作用域中被聲明的匿名内部方法因為持有該作用域中變量的引用而導緻了該作用域未被垃圾回收,js 中的閉包雖然可以借此來幫助我們實作命名空間的隔離,但也會帶來記憶體洩漏問題。

執行結果:

java8 閉包_從 λ 演算看 JS 與 JAVA8 閉包

我們再看一段修改函數定義上下文中自由變量的例子:

var fun=function(){var a='我是外部函數的變量a';var getA = function(){console.log(a);}var setA = function(){a+=',我被改了';}return{

getA:getA,

setA:setA

}

}var result=fun();

result.getA();

result.setA();

result.getA();

執行結果:

java8 閉包_從 λ 演算看 JS 與 JAVA8 閉包

可以看到 js 中對傳遞給内部函數的自由變量的修改沒有限制。實際上,在JavaScript中,一個新函數維護一個指向它所定義的封閉範圍的指針。這個基本機制允許建立閉包,這儲存了自由變量的存儲位置 - 這些可以由函數本身以及其他函數修改。JavaScript使用閉包作為建立“類”執行個體:對象的基本機制。這就是為什麼在JavaScript中,類似的函數 MyCounter稱為“構造函數”。相反,Java已經有類,我們可以以更優雅的方式建立對象。

在 js 中當閉包函數調用時,它會動态開辟出自己的作用域,在它之上的是父函數的永恒作用域,在父函數作用域之上的,是window永恒的全局作用域。閉包函數調用完了,它自己的作用域關閉了,從記憶體中消失了,但是父函數的永恒作用域和window永恒作用域還一直在記憶體是打開的。閉包函數再次調用時,還能通路這兩個作用域,可能還儲存了它上次調用時候産生的資料。隻有當閉包函數的引用被釋放了,它的父作用域才會最終關閉(當然父函數可能建立了多個閉包函數,就需要多個閉包函數全部釋放後,父函數作用域才會關閉)。

與之相比,java 對閉包的支援顯得并不是那麼完善。當然,硬來的話我們也可以用 java 模拟出類似 js 的閉包,比如:

public ConsumergetCosumer(){

StringBuilder strBuilder=new StringBuilder("原自由變量");

Consumer f=(inConsumer)->{

System.out.println(strBuilder.toString());

};

strBuilder.append(",被外層函數修改了");returnf;

}

我們不能改變 strBuilder 指向的位址,但我們可以修改該位址中對象的内容。但并沒有什麼必須要使用這種不怎麼優雅的寫法的場景,是以我們很少見到它。