Rにおける遅延評価とスコープ

去年書いたんだけど,今ふいに遅延評価ってなんだっけとか思って調べたり上の記事を読み直したりしてみたら割と何書いてあるか分からない理解してなかったっぽいのでもう一度まとめ直し.
ただ個人的には納得したものの,内容的には前のと大差ない気がします.
まず次の例を見てもらいたい.

> ## 一見するとflist[[1]]()が1,flist[[2]]()が2, ... , flist[[10]]()が10を返しそう
> flist <- lapply(1:10, function(i){ function() i})
> ## ダメ...
> flist[[1]]()
[1] 10
> flist[[2]]()
[1] 10

別にこれはバグとかではなくて,なぜこのような事が起こるのかというと,遅延評価,クロージャ,レキシカルスコープといったRの仕組みによるもので,これらを理解すれば原因と解決法が分かる(はず).ちなみに,lapply()を使った上記の書き方はforを使って次のように書き直すこともできる.人によってはこっちのが見やすいかもしれない.

flist <- list()
for(i in 1:10) flist[[i]] <- function() i

遅延評価

表現式(expression)や関数呼び出し(call)といった式をその場で評価せずに後で評価する仕組みがある.以下表現式の例

> ## 表現式オブジェクト
> ex <- expression(1 + 0.9)
> ## 未評価のまま式が保存される
> ex
expression(1 + 0.9)
> ## 未評価なので表現式中に存在しないオブジェクトがあっても大丈夫
> ex2 <- expression(a + b)
> ex2
expression(a + b)
> ## eval()で評価できる
> eval(ex)
[1] 1.9
> eval(ex2)
 以下にエラー eval(expr, envir, enclos) :  オブジェクト 'a' がありません

関数中の遅延評価

関数呼び出しの際に,実引数として単なるオブジェクトではなく式が与えられると,多くの場合,実引数は遅延評価(lazy evaluation)の対象となる.すなわち引数とそこで指定された式は,関数内部でその引数が初めて利用されたときに評価される.(U.リゲス『Rの基礎とプログラミング技法』p.78)

以下は本の例を少し変えたもの.

> ## 関数の実引数にオブジェクトではなく式が与えられた場合,遅延評価の対象になる
> lazy.f <- function(x, compute =TRUE){
+   if(compute) x
+   a
+ }
> ## 仮引数xが呼び出されなければa <- 3は評価されず,オブジェクトaも作成されない
> lazy.f(a <- 3, compute = FALSE)
 以下にエラー lazy.f(a <- 3, compute = FALSE) : 
   オブジェクト 'a' がありません 
> ## 仮引数xを呼び出した時点でa <- 3が評価される
> lazy.f(a <- 3)
[1] 3

substitute()やquote()を使用するとオブジェクトを未評価のまま返す.

> substitute(1 + 2)
1 + 2
> class(substitute(1 + 2))
[1] "call"
> eval(substitute(1 + 2))
[1] 3
> quote(1 + 2)
1 + 2
> class(quote(1 + 2))
[1] "call"

これを利用すると,関数中で実引数が呼び出されるまでは未評価であることが確認できる.

> f <- function(x) return(list(call = substitute(x), value = x))
> f(1 + 2)
$call
1 + 2

$value
3
> f(1 + 2)$call
1 + 2
> is.call(f(1 + 2)$call)
[1] TRUE
> eval(f(1 + 2)$call)
[1] 3

と以上の説明だと式のみが遅延評価の対象になるように思えるが,別に「単なるオブジェクト」を実引数に渡しても遅延評価の対象になる.

> f <- function(x) return(list(call = substitute(x), value = x))
> a <- 3
> f(a)
$call
a

$value
[1] 3

substitute(x)の時点でaが評価されていないのが分かる.ただ,存在しないオブジェクトを関数に渡すことはできない.

> rm(a)
> f(a)
 以下にエラー f(a) :  オブジェクト 'a' がありません 

よって「単なるオブジェクト」を渡す場合,必然的にそれは評価済みである.その意味で「実引数として単なるオブジェクトではなく式が与えられると」ということなのだろう.ただ,「単なるオブジェクトも遅延評価の対象である」ということを忘れていると,最初の例のようにハマることになる.


追記1 id:Mozkさんからコメントで指摘を頂きました.substitute()やquote()は「オブジェクトを未評価のまま返す」関数ではありません.substitute()は未評価の表現式に対する構文木(parse tree)を返す関数で,substitute()に渡されたオブジェクトが評価された後でも評価前の構文木を返します.つまり,substitute(x)をxの後に実行しても結果は変わりません.

> func.call <- function(x) return(list(x, substitute(x)))
> func.call(1 + 2)
[[1]]
[1] 3

[[2]]
1 + 2

遅延評価の強制

ちなみに遅延評価を強制的に発生させることもできる.関数delayAssign()は第一引数に与えられた名前のオブジェクトに第二引数の式の返り値を代入するという意味で"<-"とさほど変わらない機能を持つが,第二引数に与えられる式の内容は遅延評価の対象になる.すなわち,delayAssign()により作成されたオブジェクトが呼び出されるまで第二引数に与えられた式は評価されない.以下の例を見れば分かると思う.

> rm(x, y, temp)
> ## 第一引数に対し第二引数の返り値を代入する
> ## 第二引数の式は第一引数のオブジェクトが呼び出されるまで評価されない
> delayedAssign("temp", {x <- 1; y <- 2; 5})
> ## まだ存在しない
> x
 エラー:  オブジェクト 'x' がありません 
> y
 エラー:  オブジェクト 'y' がありません 
> ## {x <- 1; y <- 2; 5}では最後の実行式の値が5なので5が代入される
> temp
[1] 5
> ## 評価されたので存在する
> x
[1] 1
> y
[1] 2

クロージャ,レキシカルスコープ

Rには環境(environment)という仕組みがあり,例えば現在のワークスペースは.GlobalEnvと呼ばれる環境に位置している.そして,ワークスペースの中で関数を呼び出すと,その関数の環境が.GlobalEnvの下位に作成される.
そして,関数中で作られたオブジェクトはその関数の内部から参照できるが,外部のオブジェクトには影響を与えない.

> ## Rでは関数は環境を作り出し,関数中で作られたオブジェクトはその環境中のオブジェクトとなる
> fenv <- function(){
+   x <- 321
+   x
+ }
> x <- 123
> x
[1] 123
> ## 環境中で作られたオブジェクトは他の環境中のオブジェクトに影響を与えない
> fenv()
[1] 321
> ## また,関数が終了すればオブジェクトは環境とともに消去される
> x
[1] 123

さらに,もしその環境中に呼び出されたオブジェクトが見つからなければ,Rは上位の環境中へオブジェクトを探しに行く.もし見つかればそれを使う.

> fenv2 <- function(){
+   print(x)
+   x <- 321
+   x
+ }
> fenv2()
[1] 123
[1] 321

つまり,関数は自らの上位に位置する環境中に含まれるオブジェクトのことを分かっている.このような関数と環境をまとめてクロージャと呼ぶ.そして,クロージャにおいて使われているスコープ規則のことを静的スコープ,あるいはレキシカルスコープと呼ぶ.

問題の原因と解決

さて最初の問題に戻ろう.

> flist <- lapply(1:10, function(i){ function() i})
> flist[[1]]()
[1] 10
> flist[[2]]()
[1] 10

これは遅延評価とクロージャという仕組みが上手いこと働いた(働いてしまった?)結果と言える.
つまり

  • .GlobalEnvの下にlapplyの環境ができる*1
  • lapplyの下にfunction(i){...}の環境が1~10までの10種類別々にでき,そこにfunction() iが置かれる
  • function(i){...}にはiが10回渡されるのだが,iはそれぞれのfunction(i){...}中では呼び出されていない(function() iという関数定義では評価されない)ので数値としてではなく未評価のオブジェクトとして残り,遅延評価の対象となる

要するにこんな感じに環境ができる(簡単のためにiが1:3の場合).

そして例えば

flist[[3]]()

を実行したとしよう.このとき

  • function() iによりiが呼び出される
  • function(3){...}で作成された環境中で評価済みのiを探す→見つからない
  • lapply()で作成された環境中でiを探す→見つかる
  • lapply()中のiはループ変数なのでループ終了時の値(=10)になっている

追記2 この部分についても指摘を頂きました.この記述ではあたかもfuncion(3){...}で作成された環境中にオブジェクトiが存在していないかのように読めてしまいますが,オブジェクトiは未評価のiとして存在しています.

> fl <- lapply(1:10, function(i){
+   function(){return(list(ls = ls(envir = environment(fl[[3]])), value = i))
+            }})
> fl[[3]]()
$ls
[1] "i"

$value
[1] 10

未評価(というよりその関数に拘束されていない)の変数が見つかったときにスコープ規則が適用され,.GlobalEnv方向へ変数の検索が開始される,という感じでしょうか.どうもまだ把握しきれていません.
つまり,呼び出されたオブジェクトはこんな感じの順番で探される.

この場合のiならばlapplyで見つかるので.GlobalEnvまでは探しに行かない.
ここまでで分かったように原因はiが評価されないままfunction() iが定義されてしまった点にある.つまり,function() iの定義直前にiを評価してやれば,その時点でのiの値がfunction() iと同じ環境に保存されるので,目的とする挙動が得られるはずだ.
オブジェクトを強制的に評価するにはforce()を使う.

> flist2 <- lapply(1:10, function(i){ force(i); function() i})
> ## { force(i); function() i }の返り値はfunction() iなのでforce(i)は結果に影響しない
> flist2[[1]]()
[1] 1
> flist2[[2]]()
[1] 2

ただ,別に普通に呼び出すだけでもオブジェクトは評価されるので,適当な関数で呼ぶか,普通に名前を呼ぶだけでもいい.

> flist3 <- lapply(1:10, function(i){i; function() i})
> flist3[[1]]()
[1] 1
> flist3[[2]]()
[1] 2

もちろん,substitute()中で呼び出すなどの評価を伴わない呼び出しはダメ.

> flist4 <- lapply(1:10, function(i){substitute(i); function() i})
> flist4[[1]]()
[1] 10
> flist4[[2]]()
[1] 10

というわけで今回のポイントは

  • 関数呼び出しのとき渡された実引数は関数中で対応する仮引数が呼び出されるタイミングまで未評価のまま(遅延評価)
  • オブジェクトが呼び出されたとき,現在の環境に見つからないと上位の環境までその名前のオブジェクトを探しに行く(レキシカルスコープ)

の2点でした.

*1:代入("<-")とかも関数なので本当はもっと色々できてるのかもしれない