関数型Scala(4):クロージャ - Mario Gleichmann

この記事はMario Gleichmann氏による、「Functional Scala」シリーズの第4回「Functional Scala: Closures | brain driven development」を、氏の許可を得て翻訳したものです。(原文公開日:2010年11月15日)




関数型Scalaの第4話にようこそ!


今回はあちこちで議論されているクロージャを詳細に調べ、クロージャに関する誤解を解きたいと思います。単純な関数とクロージャを混同している議論が非常に多いからです。第一に、クロージャはある特別な特徴(これについて話していきます)を備えた関数です。その特徴によって、関数はクロージャになるのです。


一般的な関数の定義と、その特徴については、これまでの3話を通じてすでに深い知識を得ていますので、もう時間を無駄にせずに、関数の他の例を見てみましょう。この関数は、与えられた整数値のリストを、そのリスト内にある最初の要素によってフィルタリングすることを意図しています。つまり、最初の要素よりも小さい値はすべて結果に含まれることになります。確かに、この関数にはそれほど意味はありませんが(しかも、リストが空だとうまく機能しません)、クロージャの世界に対する、適切な入り口にはなっています:

val belowFirst = ( xs : List[Int] ) => { 

    val first = xs( 0 ) // ★

    val isBelow = ( y : Int ) => y < first // ★★

    for( x <- xs; if( isBelow( x ) ) ) yield x // ★★★
} 

... 
belowFirst( List( 5, 1, 7, 4, 9, 11, 3 ) ) // => List( 1, 4, 3 ) 

ちょっと待って下さい!ここで何が起きているのでしょう?実際、この例には新しい機能がいくつか登場しています。関数の中身の仕組みを一行ずつ理解していきましょう:


3行目(★)には目新しいことはありません。この式は単に、与えられたリスト内にある最初の要素を、値 first を宣言することで参照しています。そして、その値の型は、Int でなければなりません。整数値のリストから取得した要素だからです(このワクワクする仕事は、コンパイラ型推論能力に委ねます)。こうすることで、最初の要素を参照する際、この別名を使用できるようになりました・・・


よろしい、値の宣言の1つは、理解できました。もう1つは5行目(★★)にあります。それでは、詳細に見てみましょう。値 isBelow の型を当てられますか?そう、これはもう一つの(ローカルな)関数です。これについては、問題にならないでしょう。型が普通の(第一級の)値であり、他の値(例えば、Int 型の値 first )と比べて、それ以上でもそれ以下でもないからです。あなたが、Haskellについてよく知っていれば、このネストされた関数がwhere句で定義されていると考えるかもしれません。さて、このローカル関数が今回クロージャとしての資格を与えられていることがわかります。これについての詳細は、すぐに見ていきます。


この調査を適切に完了するため、7行目(★★★)を見てみましょう。ここで行われているのは、いわゆるリストの内包です(これについては別のエピソードでとりあげます)。さしあたり、これについては、入力されたリスト xs の各要素を見て、ifによるガード節に適合するかどうかで、出力されるリストに(内包表記全体の結果となる値として)設定するかどうかを決定しているのだと考えて下さい。このリストの内包表記は、関数内での最後の表現なので、これも関数の結果となります。

開いた項

ネストされたローカル関数を調べて、いくらか不審に思ったかもしれません。コンテキストから取り出し、個別に細かく見てみましょう:

...
val isBelow = ( y : Int ) => y < first
...

関数は結果を算出する際、引数だけに依存すると言いませんでしたか?そうだとすれば、変数 first はどうなるのでしょう(命令型の言語でいう可変な変数ではありませんね)?宣言された引数は y だけです。したがって、関数内での y の使用はその引数に束縛されます。他に引数はないので、関数の本体での first の使用は引数に束縛されません!関数の引数として宣言されたり、ローカルで導入されない引数を見つけたら、おめでとう、あなたが見つけたのは、いわゆる自由変数(free variable)です。話はさらに続きます。少なくとも1つの自由変数を含んでいる関数は、開いた項と呼ばれます。完全な関数型の関数に到達するには、あらゆる自由関数は束縛されなければなりません(これは、開いた項を閉じた項にすることで実現されます)。このために、コンパイラはいわゆるレキシカルな環境*1に向かいます。この環境において関数が定義され、束縛の対象を探そうとします。このプロセスが囲い込みclosing over )と呼ばれ、その結果が、閉じた表現、あるいはより短く、クロージャと言われるのです。


上記の例において、ローカル関数が定義されるスコープは、レキシカルな環境の中で最も近いローカルの部分でした。それは、ローカル関数を取り巻く関数の本体です。そして、そのスコープの中で、自由変数は値の定義である first に束縛され、与えられたリストの中の最初の要素を参照するのです。しかし、自由変数が周囲を取り巻く関数の本体のスコープ内で値に束縛されなかった場合には、どうなるでしょう?そうですね、束縛プロセスは完了しないでしょう。このような場合、コンパイラは適切な束縛を求めて検索範囲を広げる必要があります。しかし、次はどこを探すのでしょう?周囲を取り巻く関数の引数も、レキシカルな環境に属しています。したがって、クロージャの中の自由変数も、引数に束縛される可能性があります(そのおかげで、後のエピソードで見ていく、強力な抽象化が引き起こされます。ここでは、クロージャの持つ力を、特に高階関数と紐づけて言及する必要があります。これまでいつも高階関数に触れてきたからです)。そこまで見ても束縛する対象がなければ、コンパイラはスコープを拡大し、周囲を取り巻く関数自体が定義されている領域を探します。したがって、次のシナリオは完全に合法的なのです:

val offset = 3 
...
val below = ( barrier :Int, xs :List[Int] ) => { 

    val isBelow = ( y : Int ) => y < barrier + offset 

    for( x <- xs; if( isBelow( x ) ) ) yield x 
}

... 
below( 5, List( 5, 1, 7, 4, 9, 11, 3 ) ) // => List( 5, 1, 7, 4, 3 )

よろしい、関数 isBelow の中には2つの自由変数、barrier と offset があります。そして、barrier は周囲を取り巻く関数の第一引数に束縛される一方で、コンパイラは、offset に対する妥当な束縛対象を見つけるために、さらに外へと閉じるスコープを広げなければなりません。全体としては、もはや怖いものはありません。単なるシンプルな普通のクロージャです!

束縛 - 値(val) vs. 変数(var)

自由変数が不変の値に束縛される限り、関数型の世界ではすべてがうまくいきます。クロージャの複数回呼ばれても、その値が変わることはないので、少なくとも、束縛変数に依存する箇所では、関数の結果となる値が変わることはありません(ただし、引数が同じなら、ですが)。そして、もちろん、逆もまた然りです。つまり、クロージャ自体は、束縛変数を変えることができず、したがって、一切の副作用をもたらすことができないのです。したがって、全体として見れば、扱う値がすべて不変であれば(もしくは、その言語に値の割り当てという考え方がなければ)、クロージャであっても純粋な関数になるのです。


しかし、Scalaではどうでしょう?Scalaでは、可変な変数が提供されており、したがってそうした変数の(再)割り当ても提供されているので、クロージャが純粋な関数かどうかは、Scalaがある種の静的束縛を行うのか、それとも動的型づけを行うのかにかかっています。静的束縛においては、自由変数が(不変な値)に直接束縛されるのに対して、動的型づけでは、自由変数が、値を格納できるメモリ内の相対的な位置に束縛されます(そして束縛された位置にある値は変化するかもしれません)。さて、このScalaのふるまいは、単純な実験を行うことで明らかにすることができます。

var minAge = 18 

val isGermanAdult = isAdult( 20 ) // => true 

minAge = 21 // ★

val isUsAdult = isAdult( 20 ) // => false 

おそらく、あなたはもう何が起きているのか明確にわかるようになっているのではないでしょうか?関数 isAdult は、自由変数 minAge を含むことからクロージャであるようです。自由変数は、周囲を取り巻く環境において、変数に束縛されます。minAge が不変の値ではなく、可変の値として宣言されていることに注意してください!それでは、「★」の行を、より詳細に見ていきましょう。ここで変数の値が変更されています。これは、同じ引数に2度適用されているクロージャ isAdult に対する2回の呼び出しのちょうど中間で行われています。まさか!ここで書いた関数の結果は、関数呼び出しの間に束縛される変数が変わったことに伴い、異なる値になります。


では、どういう種類の束縛が行われているのでしょうか?クロージャが、変数の変化に合わせて異なるふるまいをしていることから、動的であるに違いありません。関数が定義された時には、束縛は値を参照せず、メモリ上の位置を参照するのです。またしても、Scalaにしてやられたわけですが、これはScalaがオブジェクト(命令型)と関数型のハイブリッド言語であり、命令的な文法構造を許可していることによるものです。クロージャの構築と使用に関して言うと、ここでもまた純粋ではない関数を作り出してしまう罠に陥ってしまうかもしれないということを意味します。

ポイント

それでは、今回のポイントは何でしょうか?
今回見てきたのは、クロージャがある特殊な関数であり、関数を取り巻くレキシカルなスコープ内で値に束縛される自由変数を参照するということでした。「なるほど、それから?」とあなたは不思議に思って尋ねるかもしれません。「本質的には一般的な関数に関する議論をほとんど理解したという点を除けば、クロージャについては部分的にか理解していません」と。でも、それがどうだというのです?今回は、その問いを完全に満足させる答えは出せません。この後のエピソードでわかることですが、クロージャはさまざまな領域で本質的な役割を果たします。これは、単純な状態の表現にはじまり、モナドに至ります(ここで、目が覚めたのではないでしょうか。モナドは今日とても流行っていて、チームメンバや友人に感銘を与えることができるでしょうから)。


そして、何度か我々が発見したことがもう1つあります。Scalaは命令型と関数型のやり方の両方に対して大いなる自由を与えますが、混ぜ合わせようとすると問題に行き当たるでしょう。関数や、関数の息づく環境に、状態を導入すると、とたんに純粋ではない関数を作り出してしまう危険が生じます。 そのことは、覚えておかなければなりません。もちろん、オブジェクト指向と関数型の考え方をブレンドすることで得られる選択肢から利益を享受することはできます。しかし、自由度が広がれば、規律も強めなければならないのです。




プログラミングScala

プログラミングScala

Scalaスケーラブルプログラミング[コンセプト&コーディング] (Programming in Scala)

Scalaスケーラブルプログラミング[コンセプト&コーディング] (Programming in Scala)

*1:静的スコープ、もしくは構文スコープとも言われる。クロージャは変数の解決を、実行時の環境ではなく、関数が定義された環境に基づいて行う。クロージャ - Wikipedia