読者です 読者をやめる 読者になる 読者になる

uehaj's blog

Grな日々 - GroovyとかGrailsとかElmとかRustとかHaskellとかFregeとかJavaとか -

Groovyのクロージャはちゃんとしているか?

Groovy

先日参加したStartupGroovy #1にて、参加者の方から「Groovyのクロージャはちゃんとしたクロージャか?」という質問がありました*1

クロージャが「ちゃんとしている」とは何か

「クロージャがちゃんとしてる」とは何を意味するか、あるいは「ちゃんとしたクロージャとは何か」は、良く考えると結構難しい*2です。でもここではあまり厳密に考えないで、

「クロージャ中から参照される変数のスコープと生存期間に関して、『仮にその場所にあるのがクロージャではなくブロックで書かれていた』と想定したとき、それと同じように動作すること」

とでもしておきます*3。オレオレ定義です。

言いかえると、クロージャが「ブロックを偽装したメソッド」だとして、その偽装がうまくいっている、ということです。

さて、Groovyの現在の実装においては、クロージャ中のコード(に対応するバイトコード)は、そのクロージャが置かれているメソッド中にあるのではなく、別のクラス(Closureクラスのサブクラス)のメソッド中のコードとして生成されて実行されるので、偽装は思うほど単純ではありません。

検証していきましょう。

クロージャ中のコードからの変数参照が、レキシカルスコープかどうか

レキシカルスコープとは、クロージャ中のコードから参照される変数は、そのクロージャが生成されたコード上の位置から字面上見えていたものにコンパイル時に静的に確定しており、そのクロージャインスタンスをどこに持ちまわしてどこで呼ぼうとも参照することができる、ということです。

Groovyコードを使って確認してみましょう。ブロック文、ここではif文のブロックを使って別スコープの同名の変数aを定義し、クロージャが正しい値を見ているかを確認してみます。

def clos
if (true) {
 def a = 3
 clos = { println a } // (1)
}

if (true){
 def a = 10 //同名の変数
 clos() // ==> 3が表示される(2)
}

このように、(2)のclos()を呼び出した位置から見えるa(値10)などに惑わされず、クロージャがレキシカルに置かれていた位置(1)から見えるa(値3)を表示します。まずはオッケー。

メソッドのローカル変数に関して

次に、クロージャをメソッド中で使っている場合を考えます。クロージャがそのメソッドのローカル変数や引数を参照しているとき、そのメソッドからリターンしても、クロージャが参照している変数を参照し続けていることができるでしょうか。

JVM上では一般に、ローカル変数はそのメソッド呼び出しのためだけにスタック上に一時的に確保され、メソッドがリターンするとスタック上のその領域は解放されるものなので、このことは自明ではありません。

やってみます。

def m() {
 def a = 3
 return { println a }
}

c = m()
c() // ==> 3が表示される。

無事できました。ぱちぱち。
この実験結果は、クロージャから参照されるローカル変数は、スタック上には配置されていないことを暗示しています。

ローカル変数は複数のクロージャから正しく共有参照されるか

次行きます。メソッド中で定義された複数のクロージャ(例えばclosA,closB)が、そのメソッドのローカル変数xを共に参照している場合、xはクロージャ間でシェアされているべきです。closAから見えるxと、closBから見えるxは同じものであり、closBからxに対して行った変更は、closAから見ても変更されているように見えなければなりません。

なぜなら、ブロックであればそう動作するからです。

そうなってるかな?

def method() {
 def x = 1
 return [ { println x }, {x++} ]
}

def (closA, closB) = method()
closB()
closB()
closB()
closA() // ==> 4が表示される

ちゃんとなってます。このことが意味するのは、クロージャはローカル変数にアクセスできるようにするために、クロージャ側に値のコピーはしていない*4ということです。

クロージャから参照される変数は、メソッドの再帰呼び出しにおいても正しく動作するか

一般に、ローカル変数はスタック上に配置されるので、領域はメソッドの呼び出し毎に別に割りあてられます。直接もしくは間接的な再帰呼び出しにおいて、同じローカル変数でも別の呼び出しにおいてはその確保される領域は別個に存在していなければなりません。言い換えると、メソッドはクロージャを使っていてもリエントラントでなければなりません。

ちょっと面倒ですが、以下のようなコードで試してみます。

writers = [null, null, null]
readers = [null, null, null]

def method(int nestCounter) {
    int localVar = nestCounter*100
    if (nestCounter >= 3) {
        return
    }
    method(nestCounter+1)
    readers[nestCounter] = { println localVar }
    writers[nestCounter] = { localVar++ }
}

method(0)

writers[0](); readers[0]() // ==>1
writers[0](); readers[0]() // ==>2
writers[0](); readers[0]() // ==>3

writers[1](); readers[1]() // ==>101
writers[1](); readers[1]() // ==>102
writers[1](); readers[1]() // ==>103

writers[2](); readers[2]() // ==>201
writers[2](); readers[2]() // ==>202
writers[2](); readers[2]() // ==>203

readers[0]() // ==>3

詳しいコード解説は省きますが、要するに再帰のネストレベルごとにローカル変数にアクセスするクロージャをとっておいて、それを経由してローカル変数の値を調べたり変更してみたりして、上書きされているかどうかを確認するコードです。

結果としては、レベル0でwriters[0]を使ってlocalVarに1、2そして3と設定した値は、レベル1およびレベル2で101、102、103、201、202,などと設定した後でも、レベル0のreader[1]で確認すると「3」のままとなっており、ちゃんと保存されています。

これが意味するのは、クロージャからアクセスされるローカル変数の実体は、メソッドの呼び出しごとに別々に確保されているだろう、ということです。

ローカル変数の確保場所は、メソッドが定義されているクラスのメンバ変数ではないだろう、ということも推測できます。

そしてクロージャのインスタンスは、そのクロージャを含むメソッドの呼び出しにそれぞれ紐付いていなければならず、クロージャ内のコードに含まれているローカル変数への参照は、どの呼び出しに対して確保されたものかを区別して保持しているはずです。

結論

さて、Groovyのクロージャは、振舞いとしては、基本的には「ちゃんとしている」ことがわかりました。ほっ。

べ、別に意外な結論が出る心配なんかしてないんだからねっ!

次回は、クロージャから生成されるバイトコードを調べて、上のような振舞いができるようなクロージャが実際にはどのように実現されているかを紐といていく予定です。

我ながら、地味ですな。でも気になっちゃったんだからしょうがない。

*1:講師の答えは「ちゃんとしている」というものでしたね。私もそう思います。

*2: [http://d.hatena.ne.jp/hidemon/20080817/1218983149:title=クロージャの定義] なども参考になります。

*3:returnやbreakの挙動とかも、気にすべきかもしれませんが、置いておく。

*4:なお、Javaの内部クラスでローカル変数をアクセスしている場合、ローカル変数の値を内部クラスのインスタンス生成時にコピーしてるんじゃないかなと思います。Javaの場合、ローカル変数参照できるのはfinal変数だけだから、変化しないので、もし実際にはシェアしてなくても、ばれることがないのです。また、プリミティブの場合、コピーなら、メソッド間で共有するために間接参照を介在させる必要もないので、内部クラスのメソッドのコードと通常のクラスのメソッドのコードで実行速度に差が出ることもない。

広告を非表示にする