この記事はR言語 Advent Calendar 2024の20日目の記事です。
前知識
Rの関数の引数は遅延評価
たぶん関数型言語?のRは言語仕様として関数の引数はpromise型(表現式+環境)となり、すべて遅延評価されます。
遅延評価自体はその値が実際に必要になるまで評価しないというもので、場合によっては時間のかかる処理をスキップできるというものです。
Rの関数の引数は遅延評価、を利用した評価スコープの変更
Rでは、仕様として関数の引数をpromise型(表現式+環境)として関数内で受け取れるので、その表現式をすぐ評価せず、別の環境(スコープ)でevalするような一種のメタプログラミング的技法がコアパッケージでも使われています。
Rはレキシカルスコープですが、substitute関数等を使うことで評価スコープを後から変更できます。
> base::with.default
function (data, expr, ...)
eval(substitute(expr), data, enclos = parent.frame())
# substituteはpromiseから表現式だけを取り出す関数
> head(iris)
Sepal.Length Sepal.Width Petal.Length Petal.Width Species
1 5.1 3.5 1.4 0.2 setosa
2 4.9 3.0 1.4 0.2 setosa
3 4.7 3.2 1.3 0.2 setosa
4 4.6 3.1 1.5 0.2 setosa
5 5.0 3.6 1.4 0.2 setosa
6 5.4 3.9 1.7 0.4 setosa
# irisというデータフレーム(環境)内でunique(Species)を評価
> with(iris, unique(Species))
[1] setosa versicolor virginica
Levels: setosa versicolor virginica
このため、「evalを実装に使うプログラミングとかありえないwww」みたいな心無いプログラマーたちの言葉に私たちRユーザーは心の中で傷ついています。
コアパッケージでeval使ってるプログラミング言語もあるんですよ!
(evalをプロダクションで使うのはどうなの?という話には文句はない)
(「R言語の評価スコープは意味不明」等言ってくれる人は逆に好き)
メタプログラミング(NSE)パッケージ関数
このような背景のせいか分からないですが、Rには評価スコープを変えるタイプのメタプログラミング(非標準評価, NSEと呼ばれています)を使ったパッケージが多く存在しており広く受け入れられています。
十分訓練されたRユーザーはどの関数のどの引数の表現式がどの引数の環境で実行されるかを把握しています。
関数 | 内容 | 使用例 |
---|---|---|
base::with | 第一引数の環境で第二引数の表現式を評価 | with(iris, unique(Species)) |
base::subset | 第一引数の環境で第二引数の表現式を評価してサブセットを作成 | subset(iris, Species %in% c("setosa", "versicolor")) |
base::transform | 第一引数の環境で可変長引数部分の表現式を評価して対象のカラムを作成 | transform(iris, Species = stringr::str_to_upper(Species)) |
dplyr::mutate | 第一引数のデータフレームを環境として、可変長引数部分の表現式を評価して対象のカラムを作成 | mutate(iris, Sepal.Length = log(Sepal.Length)) |
dplyr::where | tidyselect内で動く独自DSLの関数で環境から得られるカラム名をフィルタリングする | select(iris, where(~is.numeric(.x))) |
ggplot2::aes | plotのx軸やy軸、色などを表現式として保持 | ggplot(iris, aes(x = Sepal.Length, y = Sepal.Width, col = Species)) + geom_point() |
表現式の扱い
遅延評価の流れでメタプログラミングの説明に入りましたが、Rにはformula型という表現式を扱う型があります。
これを用いたメタプログラミング(NSE)手法もあるのでさらに状況が混沌としています。
formula型自体は、~(チルダ)を使って、以下のような形でDSLっぽい表現式をformula型として記述できるものです。
# Sepciesを応答変数として説明変数の一次の相互作用まで行列として取り出す関数
> stats::model.matrix(Species ~ . ^ 2, data = iris) |> head()
(Intercept) Sepal.Length Sepal.Width Petal.Length Petal.Width Sepal.Length:Sepal.Width Sepal.Length:Petal.Length Sepal.Length:Petal.Width Sepal.Width:Petal.Length Sepal.Width:Petal.Width Petal.Length:Petal.Width
1 1 5.1 3.5 1.4 0.2 17.85 7.14 1.02 4.90 0.70 0.28
2 1 4.9 3.0 1.4 0.2 14.70 6.86 0.98 4.20 0.60 0.28
3 1 4.7 3.2 1.3 0.2 15.04 6.11 0.94 4.16 0.64 0.26
4 1 4.6 3.1 1.5 0.2 14.26 6.90 0.92 4.65 0.62 0.30
5 1 5.0 3.6 1.4 0.2 18.00 7.00 1.00 5.04 0.72 0.28
6 1 5.4 3.9 1.7 0.4 21.06 9.18 2.16 6.63 1.56 0.68
ただ、最近は ~ 表現式 のように右辺に表現式を書いて右辺だけ評価するような使い方をしている関数が登場してきました。
# 第一引数を.xと置いた環境を新たに作ってそれをスコープとするformulaを表現式代わりに使って関数代わりに使っている例
purrr::map_dbl(iris$Sepal.Length, ~log(.x)) |> head()
# 遅延評価で表現式をiris内で評価して、その表現式内ではformulaを表現式代わりに使って関数代わりに使っている例
> dplyr::transmute(iris, Sepal.Length.Log = purrr::map_dbl(Sepal.Length, ~log(.x))) |> head()
Sepal.Length.Log
1 1.629241
2 1.589235
3 1.547563
4 1.526056
5 1.609438
6 1.686399
formula型自体は定義時に表現式だけではなく環境も保持できます。
このため、関数内での評価を安全に行ったりdotlist(...)等のスコープチェーンを追ったりしやすいので、最近のパッケージだと遅延評価のpromise型(表現式+環境)をformula型(表現式+環境)に保持して新しくquosure型として処理していくことが多いようです(rlangパッケージ)。
メタプログラミング(NSE)のメタプログラミング(表現式書き換え)
NSEは表現式の評価スコープを動的に変更して評価するというメタプログラミングの手法ですが、表現式の書き換えというメタプログラミングは行われていません。
しかし、rlangパッケージには本格的なメタプログラミング用の関数が備わっており、文字列型と表現式の相互変換(e.g. enquo, ensym, sym)や表現式に対する表現式の部分適用(e.g. !!,{{}}等を使ったinject)等を行うことができます。
この機能を使うことで、表現式を動的に書き換え評価するといった柔軟なプログラミングが可能になっています。
例えば、ggplot2等でプロットを描画する際に、データセットを読み込んで動的に生成した変数名選択UIからユーザーが選択した変数名を、プロット表示のための表現式に差し込んで描画するといったことが可能となります。
iris_plot <- function(x, y, col, facet){
x <- rlang::sym(x) # 文字列型か表現式(symbol)の場合は代入されている文字列型を表現式(symbol)にする
y <- rlang::sym(y)
col <- rlang::ensym(col) # 表現式(symbol)の場合そのまま利用して、文字列型なら表現式(symbol)にする
facet <- rlang::ensym(facet)
print(x)
print(y)
print(col)
print(facet)
rlang::inject({ # 差し込み用の関数 !!の部分に表現式(symbol)を差し込む
ggplot2::ggplot(iris, ggplot2::aes(x = !!x, y = !!y, col = !!col)) +
ggplot2::geom_point() +
ggplot2::facet_wrap(. ~ !!facet)
})
}
y_val <- "Sepal.Width"
iris_plot("Sepal.Length", y_val, "Petal.Width", Species)
Sepal.Length
Sepal.Width
Petal.Width
Species
メタプログラミング(サブ言語処理系の作成)
前述のメタプログラミング(表現式書き換え)で、表現式(Symbol)の差し替えの際に入力が以下のパターンに分けられることに気づかれたと思います。
- 文字列を表現式(symbol)にしたもの
- 表現式(symbol)を評価して文字列にして表現式(symbol)にしたもの
- 表現式(symbol)をそのまま利用したもの
inject先の関数がSE関数ならあまり問題になりませんが、NSEの関数だと条件分岐が煩雑になりそうです。
meta_ensym <- function(symbol){
symbol <- rlang::ensym(symbol)
print(symbol)
rlang::inject({
print(!!symbol) # 引数をそのまま評価する関数
print(ggplot2::aes(!!symbol)) # 引数の表現式を保持する関数
})
}
filtered_columns <- names(iris)[purrr::map_lgl(iris, is.numeric)]
meta_ensym(filtered_columns)
filtered_columns # キャプチャした表現式
[1] "Sepal.Length" "Sepal.Width" "Petal.Length" "Petal.Width" # SE関数は普通に評価
Aesthetic mapping:
* `x` -> `filtered_columns` # NSE関数は表現式(Symbol)の方を保持してしまっている
ではNSE関数であるdplyr::select等にはあらかじめカラム名を保持した変数を使おうとすると表現式(Symbol)を取ってしまって上手くいかないのでしょうか?
filtered_columns <- names(iris)[purrr::map_lgl(iris, is.numeric)]
dplyr::select(iris, filtered_columns) |> head()
Sepal.Length Sepal.Width Petal.Length Petal.Width
1 5.1 3.5 1.4 0.2
2 4.9 3.0 1.4 0.2
3 4.7 3.2 1.3 0.2
4 4.6 3.1 1.5 0.2
5 5.0 3.6 1.4 0.2
6 5.4 3.9 1.7 0.4
あれ?2のパターンかな?
Species <- names(iris)[purrr::map_lgl(iris, is.numeric)]
dplyr::select(iris, Species) |> head()
Species
1 setosa
2 setosa
3 setosa
4 setosa
5 setosa
6 setosa
ん?3のパターン?
dplyr::select(iris, "Species") |> head()
Species
1 setosa
2 setosa
3 setosa
4 setosa
5 setosa
6 setosa
1のパターン?何で?
何で対処できているの?
答えは、dplyr::selectのカラム選択部分は専用のサブ言語とその処理系であるtidyselectで評価しているでした。
R言語ではDSL処理系も実装できるんですね。
tidyselect implements a specialised sublanguage of R for selecting variables from data frames and other data structures.
この機能のおかげで入力が表現式か文字列かどうかを気にせずdplyr::selectを扱うことができます。
(DSL用関数 > 表現式(symbol) > 文字列 の優先順はあるので多少は気にしましょう)
また、この処理系のDSLによって関数の環境をリフレクションしてカラムをフィルタリングすることが可能となっており、select関数内部で操作が完結するようになっています。
- 表現式を使って簡単に変数をフィルタリングできます。
# names(iris)[purrr::map_lgl(iris, is.numeric)]のように顕わに書く必要が無い
dplyr::select(iris, tidyselect::where(~ is.numeric(.x))) |> head()
Sepal.Length Sepal.Width Petal.Length Petal.Width
1 5.1 3.5 1.4 0.2
2 4.9 3.0 1.4 0.2
3 4.7 3.2 1.3 0.2
4 4.6 3.1 1.5 0.2
5 5.0 3.6 1.4 0.2
6 5.4 3.9 1.7 0.4
- 文字でカラム名をフィルタリングできます。
# tidyselect::starts_withはtidyselect系の関数で機能する独自DSL
dplyr::select(iris, tidyselect::starts_with("Sepal.")) |> head()
Sepal.Length Sepal.Width
1 5.1 3.5
2 4.9 3.0
3 4.7 3.2
4 4.6 3.1
5 5.0 3.6
6 5.4 3.9
- dplyr::mutateとdplyr::acrossを利用すれば、好きなカラムと好きな操作を簡明に記述できます。
iris |>
dplyr::mutate(
dplyr::across(tidyselect::where(~ is.numeric(.x)), ~ .x ^ 2),
dplyr::across(tidyselect::starts_with("Sepal."), ~ .x + 100),
dplyr::across(tidyselect::all_of("Petal.Length"), ~ .x - 100),
dplyr::across(tidyselect::where(~ !is.numeric(.x)), ~ stringr::str_to_upper(.x))
) |>
head()
Sepal.Length Sepal.Width Petal.Length Petal.Width Species
1 126.01 112.25 -98.04 0.04 SETOSA
2 124.01 109.00 -98.04 0.04 SETOSA
3 122.09 110.24 -98.31 0.04 SETOSA
4 121.16 109.61 -97.75 0.04 SETOSA
5 125.00 112.96 -98.04 0.04 SETOSA
6 129.16 115.21 -97.11 0.16 SETOSA
dplyr::across内では引数に環境を指定していないのにも関わらず列名を取り出せているのは、dplyr::acrossが現在の環境をtidyselectのDSL処理系に渡してその環境内の変数情報を探索するという動きをするためです。
- 非select関数のtidyselect準拠ラッパー関数の作成
内部で非tidyselect関数を使うがtidyselect準拠の関数を作成したい場合は公式が実装方法のドキュメントを提供しているので先述のrlang::injectとは別で参考にされると良いと思います。tidyselectが対象とする環境はデータフレームでなくリストでも可能なようです。
# 可変長引数型
get_loc_iris <- function(...){
expr <- rlang::expr(c(...))
tidyselect::eval_select(expr, iris)
}
get_loc_iris("Species" , tidyselect::ends_with("Width") , Sepal.Length)
Species Sepal.Width Petal.Width Sepal.Length
5 2 4 1
# 単一引数型
get_loc_iris <- function(expr){
expr <- rlang::enquo(expr)
tidyselect::eval_select(expr, iris)
}
# 入力の仕方がselectと少し変わります
get_loc_iris("Species" | tidyselect::ends_with("Width") | Sepal.Length)
Species Sepal.Width Petal.Width Sepal.Length
5 2 4 1
叫び
もうさァッ!無理だよ!スコープ分かんないんだからさァッ!
R関数の評価順をもっと勉強しないとさァッ!
なんでパッケージ関数にevalがあんだよ!
教えはどうなんてんだ教えは!
なんだよ、... ..n !! {{}} って
知らなかったの、俺だけかよ!?
オイ…何で…
パッケージ関数中でAST walkしている…
定義時環境や実行時環境のせいじゃなくて…
俺が悪いんだよ
お前の評価がバグったのはquosureを使わず引数をキャプチャした俺のせいだ!!
レバヘ! tidyselect語ヤヌサー!
ドリル
標準評価
Rの関数の引数は標準評価の時点で遅延評価する仕様ですが、スコープが分かりづらいので関数を実行しながら確認していきます。
1. 遅延評価
f_a <- function(x, side_effect = message(x)){
x
}
f_a(1)
答え
[1] 1
ネイティブRユーザーは違和感を感じないのですが、正しい反応としては「なんで関数定義時にmessage(x)のところでxの未定義エラーが出力されないの?」です。
(引数に副作用のある処理を入れるべきではない等の指摘は除く)
そうです。message(x)は遅延評価されるので定義にxが無くても怒られません。
また、side_effectは関数内で使われていないので実行されないです。
2. 遅延評価
f_a <- function(x, side_effect = message(x)){
side_effect
x
}
f_a(1)
答え
1
[1] 1
side_effectの評価が入ったのでmessage(x)が実行されました。
3. 遅延評価
f_a <- function(x, side_effect = message(x)){
x <- x + 1
side_effect
x
}
f_a(1)
答え
2
[1] 2
side_effectの評価の前にxの更新が入ったのでmessage(x)は2を出力します。
4. 遅延評価
難問
f_a <- function(x, side_effect = message(x)){
side_effect
x <- x + 1
side_effect
x
}
f_a(1)
答え
1
[1] 2
1
2
[1] 2
とはなりませんでした。
side_effectの評価値はmessage()の返り値のNULLになるので二回目のside_effectの実行をせずにそのまま一回目の評価値を利用します。
この動きのおかげで時間のかかる処理をスキップすることができます。
ただ今回のように副作用のある関数を使う場合は思うような評価タイミングにならないかもしれません。
5. 遅延評価
難問
f_a <- function(x, side_effect = message(x)){
side_effect
side_effect
x
}
f_a(1, side_effect = print(x)) # 実行時にside_effectを定義
答え
Error: object 'x' not found
xが定義されていないと怒られます。
関数定義時の引数のプロミスの環境は関数内ですが、関数実行時の引数のプロミスの環境は実行スコープなのでxの未定義エラーが出力されます。
プロミス(表現式+環境)は触ろうとするとすぐ評価されてしまうため、どの環境を使っているかは直接確認できません。
だた、rlang::enquo()するとクオージャ(表現式+環境, 関数+環境のクロージャと似ていますね。)として保持できるのでこちらで環境を調べてみます。
f_a <- function(x, side_effect = message(x)){
side_effect <- rlang::enquo(side_effect)
print(side_effect) # quosureを表示
print(environment()) # 関数内の環境を表示
rlang::eval_tidy(side_effect) # quosure型をevalする専用の関数
eval(side_effect[[2]]) # quosure型自体は右辺を表現式とするformula型なので[[2]]をevalすればbaseでも評価できる
x
}
f_a(1)
f_a(1, side_effect = print(x))
> f_a(1)
<quosure>
expr: ^message(x)
env: 0x00000178e9953368
<environment: 0x00000178e9953368>
1
1
[1] 1
> f_a(1, side_effect = print(x))
<quosure>
expr: ^print(x)
env: global
<environment: 0x00000178e90d2e60>
Error: object 'x' not found
関数定義時の引数の環境は関数内の環境で、関数実行時の引数の環境は実行時の環境ということが確認できました。
6. 遅延評価
x <- 1
f_a <- function(x, side_effect = message(x)){
x <<- x + 1
side_effect
x
}
f_a(2)
x
答え
> f_a(2)
2
[1] 2
> x
[1] 3
外部のxにx+1が代入されますが、内部のxは未更新なのでもともとの値が入ります。
7. 遅延評価
x <- 1
f_a <- function(x, side_effect = message(x)){
x <<- x + 1
side_effect
x
}
f_a(2, side_effect = print(x)) # 実行時に副作用を定義
x
答え
> f_a(2, side_effect = print(x))
[1] 3
[1] 2
> x
[1] 3
実行時のスコープは関数の外なので外のxが評価されます。
8. 可変長引数環境での表現式の評価
難問
f_a <- function(formula){
function(...){
eval(formula[[2]])
}
}
f_a(~ ..1 ^ 2)(3)
f_a(~ ..1 + ..2)(3, 4)
答え
> f_a(~ ..1 ^ 2)(3)
[1] 9
> f_a(~ ..1 + ..2)(3, 4)
[1] 7
formula型の評価を可変長引数が存在する環境(クロージャー)で行うと、..nを引数とする関数のように扱うことができます。
rlang::as_function(~ .x + .y)はこの機能を利用しています。
特に非標準の評価というわけではないのでここに配置しました。
非標準評価編(引数の表現式を別スコープで評価する)
関数定義時は引数のスコープは関数内の環境ですが、関数実行時の引数のスコープは実行時の環境でした。
以降のドリルでは、関数実行時の引数の表現式を関数内部の好きな環境で評価していきます。
9. 標準評価
f_a <- function(a, b = a + 1){
b
}
f_a(1)
f_a(a + 2)
答え
> f_a(1)
[1] 2
> f_a(a + 2)
Error: object 'a' not found
定義時の引数は評価できても、実行時の引数は評価できません。
10. 非標準評価
f_a <- function(a, b = a + 1){
b_exp <- substitute(b)
print(b_exp)
eval(b_exp)
}
f_a(1)
f_a(1, a + 2)
答え
> f_a(1)
a + 1
[1] 2
> f_a(1, a + 2)
a + 2
[1] 3
substitute関数で表現式を取り出して評価することができました。
11. 非標準評価
f_a <- function(a, b = a + 1){
b_exp <- substitute(b)
print(eval(b_exp))
a <- a + 1
print(eval(b_exp))
}
f_a(1)
f_a(1, a + 2)
答え
> f_a(1)
[1] 2
[1] 3
> f_a(1, a + 2)
[1] 3
[1] 4
関数内部のスコープで評価することができます。
12. 安全な非標準評価
表現式の評価の際により安全なrlang::enquoを使っていきます。
enquoはプロミス型から表現式だけではなく環境も保持したクオージャ型のオブジェクトを作成します。
a <- NULL
rm(a)
z <- 1
f_a <- function(a, b = a + z){
z <- 100
b_exp <- rlang::enquo(b)
print(rlang::eval_tidy(b_exp))
a <- a + 1
print(rlang::eval_tidy(b_exp))
}
f_a(1)
f_a(1, 1 + z)
f_a(1, a + 1)
f_a(1, a + z)
答え
> f_a(1)
[1] 101
[1] 102
> f_a(1, 1 + z)
[1] 2
[1] 2
> f_a(1, a + 1)
Error: object 'a' not found
> f_a(1, a + z)
Error: object 'a' not found
クオージャ型の評価では変数を探しに行く環境がクオージャの環境になります。
そのため、定義時の引数は関数内部を実行時の引数は関数外を環境として評価します。
13. 安全な非標準評価
a <- NULL
rm(a)
z <- 1
f_a <- function(a, b = a + z){
z <- 100
b_exp <- rlang::enquo(b)
print(rlang::eval_tidy(b_exp, list(a = 10)))
a <- a + 1
print(rlang::eval_tidy(b_exp, list(a = 10)))
}
f_a(1)
f_a(1, 1 + z)
f_a(1, a + 1)
f_a(1, a + z)
答え
> f_a(1)
[1] 110
[1] 110
> f_a(1, 1 + z)
[1] 2
[1] 2
> f_a(1, a + 1)
[1] 11
[1] 11
> f_a(1, a + z)
[1] 11
[1] 11
環境を明示的に指定して評価しました。
この環境で見つからなかった変数はクオージャ型の環境に探しに行くことになります。
メタプログラミングするにしても、評価に指定した環境以外の変数探索がレキシカルスコープに準ずるこの挙動が無難です。
(クオージャ型(表現式+環境)として扱うモチベーションはおそらくここ)
もし皆さんがメタプログラミングをする際はquoteではなくquosureを用いるようにしてください。
14. 安全な非標準評価
a <- NULL
rm(a)
z <- 1
f_a <- function(a, b = a + z){
z <- 100
b_exp <- rlang::enquo(b)
print(rlang::eval_tidy(b_exp, list(a = 10)))
a <- a + 1
print(rlang::eval_tidy(b_exp, list(a = a + z)))
}
f_a(1)
f_a(1, 1 + z)
f_a(1, a + 1)
f_a(1, a + z)
答え
> f_a(1)
[1] 110
[1] 202
> f_a(1, 1 + z)
[1] 2
[1] 2
> f_a(1, a + 1)
[1] 11
[1] 103
> f_a(1, a + z)
[1] 11
[1] 103
環境を明示的に指定する際に関数内部の環境を親環境に持つ環境を指定して評価しました。
この環境で見つからなかった変数はクオージャ型の環境に探しに行くことになりますが、最後のf_a(1, a + z)で渡される引数の表現式は実行時に定義されているので関数外の環境を持つクオージャとなり、そのため指定された環境の親環境(関数内)を飛ばして関数外の環境に変数探索しています。
雑感
もともと、Rの関数引数は遅延評価で処理されることは知っており、遅延評価の箇所に副作用のある処理を入れたら、評価タイミングが予測不能な形になるのではないかと思い、面白そうだと取り掛かった記事でした。
調べていく中で、Rの関数引数は定義時も実行時も「表現式(expression)+ 環境(environment)」で扱われていることが分かり、その仕組みを利用したメタプログラミング技法について深掘りするうちに、記事がどんどん長くなってしまいました。
当初は、メタプログラミングといえばこっそり行うもので、評価環境の変更(非標準評価)くらいしかパッケージとしては広く利用されていないだろうと思っていましたが、調べていくと、表現式の部分評価(quasiquotation)や言語処理系の実装にまで応用されていることを知り、自分の知識の狭さに愕然としました。
また、可変長引数の扱いやRの環境(スコープ)が作成されるタイミングについても触れるべきかと思いましたが、突き詰めすぎると元々のテーマである遅延評価の話からかけ離れてしまいそうだったため、今回の記事ではそこまで踏み込まず、このあたりでドリルを終了することにしました。
それでは、皆さん良いクリスマスをお過ごしください。