2020/05/16

R - 研究編 1 評価機構

R は、C や Java で育ったプログラマには直感的には馴染み辛い、ある種独特な評価機構を持っているので、その振舞いを他のプログラミング言語と比較する形でまとめてみました。

変数のスコープと環境

R では、基本的に構文スコープ (lexical scope) モデルを採用しています。構文スコープモデルとは、関数定義の入れ子が可能な言語において、関数が定義された「環境」によって参照される変数が決まる方式です。「環境」とは、変数の集まりである「フレーム」と、親の環境である「エンクロージング環境」からなる入れ子状の構造です。
関数が定義されると、その関数が定義された環境が、その関数自身の環境となります。この環境を「クロージャ環境」と呼びます。関数のクロージャ環境は、後から変更することもできます。
関数が呼び出されると、その関数のクロージャ環境をエンクロージング環境とする新しい環境が作られます。これを「評価環境」と呼びます。関数内での変数への付値は、その新しい環境上に行われます。
トップレベルの環境は、「グローバル環境」と呼ばれ、「.GlobalEnv」という名前を持っています。関数が定義された入れ子構造の中で発見できなかった変数は、「.GlobalEnv」から探索され、それでも見つからなかった変数は「探索リスト」と呼ばれる環境のリストを元にさらに探索が進められます。探索リストは「.GlobalEnv」環境に始まり、「package:base」環境に終わる環境のリストです。「.GlobalEnv」と「package:base」の間には、library() で読み込まれたパッケージや、attach() で組み込まれた名前空間、autoload() で指定されたオンデマンド読み込みを行う変数を保持する環境 Autoloads 等が含まれています。

まとめると、以下のように探索されます。
  1. 関数の実行にあたって作られた環境 (評価環境)
  2. その関数の環境 (クロージャ環境)
  3. そのエンクロージング環境 → そのエンクロージング環境 → ……
  4. グローバル環境 (探索リストの1番目)
  5. attach() された名前空間 (探索リストの2番目) → ……
  6. library() で読み込まれたパッケージ → ……
  7. autoload() で指定された変数を保持する環境 (Autoloads 環境)
  8. base パッケージ

環境の種類

R における環境には様々な種類があります。大雑把には前述していますが、改めて詳細に説明します。

評価環境

関数を評価する際に作成・使用される環境です。作成されたときの状態は、フレームは空で、エンクロージング環境はその関数のクロージャ環境になっています。
関数の評価の中で行われた付値は、この評価環境の持つフレームに対して行われます。

クロージャ環境

関数と、自由変数の束縛を合わせたものをクロージャ (閉包) と呼びますが、この自由変数の束縛を表す環境をクロージャ環境と呼びます。R の関数オブジェクトはクロージャであるため、関数の定義とクロージャ環境の2つの要素を持っています。クロージャ環境の取得・変更は environment() 関数を用いて行うことができます。ちなみに関数の定義は body() 関数でアクセスできます (参照もできるし変更もできる)。
下の例は、a という変数が未定義の状態で、関数 function() a を定義し、f に付値した、という状況です。
> a
 エラー:  オブジェクト 'a' がありません
> f <- function() a
> mode(f)
[1] "function"
> typeof(f)
[1] "closure"

f のモードは function、f の型は closure となっています。この状況で、f を評価すると、a が未定義なため、エラーとなります。
> f()
以下にエラー f() : オブジェクト 'a' がありません

ここから、f のクロージャ環境を変更することで、f を評価できるようにしてみましょう。
f のクロージャ環境を確認します。f はグローバル環境で定義したので、クロージャ環境はグローバル環境になっています。
> environment(f)
<environment: R_GlobalEnv>

新しい環境を2つ作ってみましょう。
> env1 <- new.env()
> env1$a <- 1
> env2 <- new.env()
> env2$a <- 2
new.env() は、new.env() を呼び出した環境をエンクロージング環境とする空の新しい環境を作る関数です。環境のフレームはリストで出来ているので、リストに値を格納する要領でフレームに変数を束縛することができます。env1 の a には 1 を、env2 の a には 2 を束縛してみました。

この2つの環境を先程の f のクロージャ環境に設定してみましょう。
> environment(f) <- env1
> f()
[1] 1
> environment(f) <- env2
> f()
[1] 2
environment(f) で f のクロージャ環境にアクセスできます。f のクロージャ環境を env1 に変更した上で f を評価すると、env1 の a は 1 に束縛されているため、1 を返します。
同様にクロージャ環境を env2 に変更してみると、2 を返します。

(補足) ML のクロージャとの比較

ML などでは、関数定義時の環境のスナップショットがクロージャ環境になります。そのため、後から関数内で参照されている名前に束縛する値を変更しても、関数の挙動は変化しません。一方 R では、関数定義時の環境の参照をクロージャ環境として保持しているだけなので、変数の値を変更すると、関数の挙動も変化します。

OCAML の例を見てみましょう。

# let a = 1;;
val a : int = 1
# let b = 2;;
val b : int = 2
# let f() = a + b;;
val f : unit -> int = <fun>
# f();;
- : int = 3
# let a = 5;;
val a : int = 5
# let b = 10;;
val b : int = 10
# f();;
- : int = 3

a = 1, b = 2 に束縛した状態で f を定義しました (1〜6行目)。f() を評価すると当然 3 を返します (7〜8行目)。
OCAML では f() は、f() 定義時の環境で動作するので、f() 定義後に a, b に違う値を束縛しても (9〜12行目)、f() の戻り値は変化しません (13〜14行目)。
この特性は、「a, b の値を変更できない」という見方もできます。そのため ML では「a, b は変数ではなく、束縛である」という言い方をよくします。

R で同じようにやってみましょう。

> a <- 1
> b <- 2
> f <- function() a + b
> f()
[1] 3
> a <- 5
> b <- 10
> f()
[1] 15

f() のクロージャ環境は .GlobalEnv です。.GlobalEnv における a, b の値を変更すると、f() の戻り値も変化します。

インポート環境

パッケージを作る際に、そのパッケージが他のパッケージからインポートした変数を保持する特殊な環境です。library() で読み込んだものより高い優先順位になっているため、あるパッケージが依存している他のパッケージと名前が競合するようなパッケージを library() で読み込んでも、そのパッケージの挙動には影響を与えません。library() の使用によって依存関係を壊してしまわないための設計です。

名前空間

あるパッケージについて、そのパッケージの中だけで参照可能な変数を保持している環境です。パッケージ内の関数のクロージャ環境は、そのパッケージの名前空間になっています。これもパッケージの内部状態を外から壊してしまわないための仕組みです。

グローバル環境

トップレベルの環境で、常に探索リストの先頭にある環境です。

データベース

リストやデータフレームといった名前と値の組からなるデータ構造を元とする環境です。attach() によってリストやデータフレームの内容をフレームに持つ環境が作られ、その環境が探索リスト上に配置されます。

パッケージ

パッケージからエクスポートされた変数をフレームに持つ環境です。

関数の引数の評価

R における関数の引数の評価にあたっては、注意点が3つあります。
  1. 引数が意味論的に値渡しとなっていること
  2. 引数が遅延評価されること
  3. 引数が評価される環境

それぞれの点について詳しく見ていきます。

引数が意味論的に値渡しとなっていること

注意点の1は、R における引数の評価戦略に関連しています。
一般的にプログラミング言語の引数の評価戦略には、大きく分けて「正格評価 (Strict evaluation)」と「非正格評価 (Non-strict evaluation)」の2種類があります。正格評価は「先行評価」とも呼ばれ、関数の実行前に全ての引数を評価してその値を「値渡し (Call by value)」するか、「変数渡し (Call by variable)」の引数は変数が渡されます。一方、非正格評価では、引数は関数の実行中にその引数が使用されるまで、評価されません。「遅延評価」とも呼ばれています。非正格評価は、「名前渡し (Call by name)」という、引数の使用の都度評価を行う方式と、「必要渡し (Call by need)」という、初めて使用されたときに評価してその結果を保持する方式とに分けられます。

さて、R はどうなっているかと言うと、「R は値渡しだが遅延評価を行う」という表現がよく見られますが、要するに必要渡しです。必要渡しは多くの局面では、値渡しと変わらない意味論を持っています。例えば以下のような関数 swap() を用意しても、変数の値の交換にはなりません。
> swap <- function (a, b) { tmp <- a; a <- b; b <- tmp }
> a <- 1
> b <- 2
> swap(a, b)
> a
[1] 1
> b
[1] 2

ちなみに変数渡しの言語では(変数渡しの引数であることを明記する必要はありますが)、このような記述で変数の値の交換を行わせることができます。
名前渡しの言語の場合は、一見うまくいきそうですが、a=i, b=x[i] といった依存関係のある引数を渡すと、使用の都度評価される関係でおかしなことになってしまいます。

R では、オブジェクトが関数の引数に渡されるとき、内部的には参照が渡されています。しかし、値が初めて変更されるときに、コピーが作られるようになっています (Copy-on-write semantics)。したがって、意味論的には値渡しとなっています。こうすることで巨大なオブジェクトを引数に渡したときの効率性を維持しつつ、値渡しの意味論を実現しています。

R には tracemem() という関数があります。コピーが発生したときにその旨を表示してくれるようなフックを仕込む関数です。これを用いてコピーが行われているかどうかを確認してみましょう。
> a <- c(1, 2, 3, 4, 5)
> tracemem(a)
[1] "<0x15768f80>"
> f <- function (x) print(x)
> f(a)
[1] 1 2 3 4 5
> f <- function (x) {x[3] <- 100; print(x)}
> f(a)
tracemem[0x15768f80 -> 0x151133e8]: f
[1]   1   2 100   4   5
> a
[1] 1 2 3 4 5

a を定義し、a に tracemem() を仕掛けます (1〜3行目)。
まず f(x) を x に書き込みを行わない関数として定義して評価してみます (4〜6行目)。tracemem() は何も言ってこないので、コピーは発生していません。
次に f(x) を定義し直し、x を変更する関数にしてみます (7行目)。それで評価してみると、tracemem() からコピーが発生した旨の通知があり、表示された内容も変更された x になっています (8〜10行目)。改めて a の内容を表示してみると、変更はされていません (11〜12行目)。つまり、意味論的には値渡しになっているわけです。

ところで、「R の環境オブジェクトは参照渡しされる」という表現をときどき見掛けます。環境オブジェクトは例外的に、意味論的に値渡しではなく参照渡しとして扱われることを指しています。しかし実際のところ、全ての引数は参照渡しになっていて、Copy-on-write によって値渡しの意味論を実現していることを考えると、「参照渡し」という言葉を使ってしまうと話がまぎらわしいと思います。「環境オブジェクトは、フレームの内容を変更してもコピーが作られない (Copy-on-write の例外)」と表現する方が誤解が無いように思います。

引数が遅延評価されること

正格評価を行う C 言語では、以下のようなコードを動かすと、123, 123 と表示されます。
void f(int x, int y) {
}
int main() {
  int x, y;
  f(x = y, y = 123);
  printf("%d, %d\n", x, y);
}

f() の引数に代入文を書いています。f() の中で引数が参照されているか否かによらず、代入は実行されています。そのため 123, 123 と表示されることになります。

非正格評価の R では、以下のようになります。
> f <- function(x, y) {}
> f(x <- y, y <- 123)
NULL
> x
 エラー:  オブジェクト 'x' がありません
> y
 エラー:  オブジェクト 'y' がありません

f() の内部で引数を参照していないため、呼び出し時の引数に書かれている付値が実行されません。これが非正格評価、すなわち遅延評価の性質です。

引数が評価される環境

R の引数が評価される環境は、呼び出し時に渡した引数と、定義時に定めた既定値が使われる引数とで異なっています。
呼び出し時に渡した引数は、呼び出し元の環境上で評価されます。
定義時に定めた既定値が使われる引数は、関数の評価環境上で評価されます。

具体的に見てみましょう。
> f <- function(x, y = a <- 10) { print(c(x, y, a)) }
> f(a <- 20)
[1] 20 10 10
> a
[1] 20
f() は引数 x と引数 y = a <- 10 を持っています。関数の内部では x, y, a の順に使用されています。
x に a <- 20 を与えて実行してみましょう。a <- 20 は呼び出し時に渡した引数ですから、呼び出し元の環境上で評価されます。まず最初に x が使用されますから、a <- 20 が評価されます。この評価は呼び出し元の環境、すなわち .GlobalEnv 上で行われますから、.GlobalEnv 上の a に 20 が付値され、x の値も 20 となります。次に y が使用されますが、y は関数呼び出し時に引数を与えていないので、既定値の a <- 10 が評価されることになります。定義時に定めた既定値が使われる引数は、関数の評価環境上で評価されますので、評価環境のフレーム上で、a に 10 が付値されて、y の値は 10 になります。最後に a が使用されます。この時点で a は評価環境にも .GlobalEnv にも定義されています。評価環境が優先順位が高く、そこでは 10 が付値されていますから、a の 値は 10 となります。結果として、20 10 10 が表示されることになります。
f() の実行が終了した後、(.GlobalEnv 上で) a の値を見てみると、20 になっていることが分かります。

予約オブジェクト

R の遅延評価機構は予約オブジェクト (Promise) によって実現されています。関数の引数の遅延評価も予約オブジェクトを用いたものです。
予約オブジェクトは、値、式、環境の3つのスロットを持つオブジェクトです。式と環境から作られ、最初は値は未定の状態になっています。
予約オブジェクトがアクセスされると、式スロットに保持している式が評価され、その値が値スロットに保持されます。以降、予約オブジェクトの評価値は、値スロットに保持している値となります。この機構が関数の引数に適用されると、必要渡しが実現されることになります。
R では、オブジェクトの正体が予約オブジェクトであるかどうかを知る直接的な方法は無く、極めて透過的に動作します。

予約オブジェクトの式スロットの内容は、substitute() 関数により取り出すことができます。
> f <- function(x) { print(substitute(x)); print(x) }
> f(1 + 2 + 3)
1 + 2 + 3
[1] 6

予約オブジェクトの環境を知る直接的な方法はありませんが、式スロットに評価時の環境を表示するような式を入れておけば、確認できます。この方法で、関数の引数や既定値引数がどの環境で評価されるかを確認してみましょう。
> f <- function(x, y = {print(environment()); "y"}) { print(x); print(y) }
> f({print(environment()); "x"})
<environment: R_GlobalEnv>
[1] "x"
<environment: 0x184d308c>
[1] "y"

先程「引数が評価される環境」で見たとおり、与えた引数は呼び出し元環境である .GlobalEnv で評価されており、既定値引数は関数の評価環境で評価されているということがわかります。

予約オブジェクトを直接作り出すこともできます。delayedAssign() を用います。
> delayedAssign("x", {print("evaluated."); 1})
> x
[1] "evaluated."
[1] 1
> x
[1] 1

まず x に予約オブジェクトを付値します (1行目)。この時点では print() は駆動しません。
2行目で x にアクセスすると、式スロットが評価され、print() が駆動します (3行目)。値スロットには 1 が入り、評価値としては 1 が返ってきます (4行目)。
もう一度アクセスしても式スロットは再評価されず (5行目)、値スロットに保持されている 1 が評価値として返ってきます (6行目)。