[R雑記] rlangのtidyeval ユーザー目線で使うとこだけ理解する

thumbnail_rlang 雑記

はじめに tidyevalは「コレ」のせいで難しい

R言語をそこそこ勉強してる人なら分かると思うのですが、存在は知っていても理解がめちゃくちゃ難しいのがrlangパッケージのtidyevalです

本気でtidyevalを学びたければHadleyのAdvanced Rを読めば良いのですが、ただでさえ難解な内容が英語なので余計難しい🤯 英語が分かっても聞き慣れない単語のオンパレード。その様はまるで某WebServiceのトップページのようです。

日本語の記事を探してみると 2017年の yutannihilation さんのスライド とか、2018年の yutannihilation さんのブログポストとか、2018年の teramonagi さんのブログポストとか、2019年の uri さんのQiita記事などがあり、その節にはお世話になりました。

しかし、どれだけ日本語記事を読んでも、未だにノーミスで!!を使えるようにならないのです😰

調べながらでないとコードが書けないということは自分はまだ本質を理解できていないということ。最近では自社Rライブラリを作る用事ができたため、とうとう仕事でもtidyevalが理解できていないとヤバい状況になってしまったため、Advanced Rを中心に勉強してきました。

本気で勉強した結果ある程度全体像が見えてきたのですが、俯瞰すると初心者トラップが多すぎる事実に気がつきました。これがtidyevalを難しくしていると思います。

tidyevalの初心者トラップ

  • そもそもrlang 1.0.0メジャーリリースは今年(2022/1/26)。過去の情報は一部古い。
    ⇒ 最新情報を読むと意外に理解しやすい👍
  • 体系的に勉強しようとすると情報量が多いが、一般ユーザーなら知らなくて良い情報が半分以上 ⇒ この記事で必要なとこだけ説明します👍
  • 聞き慣れない言葉が多過ぎてモチベそがれる
    ⇒ 本当に覚えなければならないのは5つくらい👐
  • 「baseRの仕様」と「tidyevalの仕様」の区別がつきにくい
    ⇒ tidyevalのガチ勢以外は区別付けなくていい👉

この記事では、ちゃんとした自作パッケージを開発する予定が無く一般ユーザーの域を出ない人向けに、最低限の理解でtidyevalを使った自作関数が作れるようになることを目指して原理原則だけをなんとなく理解してもらうことをゴールに設定しています⛳

具体的に「こんな場合にはこの関数を使う」といった傾向と対策は次回の記事でまとめようと思います!今回はtidyevalの原理をなんとなく理解しよう編です。

注意事項

この記事ではbaseRの関数とrlangの関数をごっちゃにして説明しています。これはそもそも使用する可能性が低い関数を引き合いに出した結果、話が複雑になることを避けるためです。

この記事は自分の理解を整理しながらアウトプットしています。間違っているところがあればTwitterで教えていただけると幸いです。

この記事の情報は、Web上で公開されているAdvanced Rの最新版およびrlangのリファレンスサイトを主な情報源としています。

ある程度情報の整理がついてくれば上記の内容を理解することが出来るようになってくると思います。その際にはAdvanced Rの方が情報に漏れがないため、さらに詳しい知識を身につけたいと望まれる方はAdvanced Rを教材とされることを強くオススメします。

Introduction | Advanced R

rlangのパッケージリファレンスはAdvanced Rの後に読む方が良いかも。

Functions for Base Types and Core R and Tidyverse Features
A toolbox for working with base types, core R features like the condition system, and core Tidyverse features like tidy evaluation.

tidyevalの基本情報

tidyevalはメタプログラミングのための技術

クォーテーションマーク(")で囲えば文字列、そうでなければ(裸の状態で宣言されたら)変数として扱う、というのはどのプログラミング言語にも存在する概念だと思います。

しかし、Rではクォーテーションがあってもなくても同じように評価できる場合があります。一番分かりやすい例はlibrary()関数です。

library("MASS")

library(MASS)

クォーテーションがあっても無くても同じ処理が実現されているということは、baseRには両者を相互に変換する仕組みがあることを示唆しています

この仕組みを最大限に利用すれば 何度もクォーテーション`"`する手間を省くことが出来、ユーザーにとって使いやすいAPIを作り出すことが出来ます[/sc]。

もとからbaseRに存在しているこれらの機能の穴を埋め、さらに機能を拡張したものがrlangパッケージでありtidyevalという機能として提供されています。dplyrggplot2といったtidyverseパッケージでは、rlangパッケージが提供するtidyevalをフル活用することで、直感的で使いやすい関数設計が実現しています。

ユーザー視点では、tidyevalを最大限活用することでプログラミングによってコードそのものを操作したり変更したりすることが可能になり、より柔軟なプログラミングが可能になります。
ここで言及した、のことをメタプログラミングと言います。

tidyevalが実現したいことは「メタプログラミングの実装により、柔軟なプログラミングを可能にする」こと。[1]「コードそのものを操作したり変更したりするプログラミング」をメタプログラミングと言います。[2]今のAdvanced Rにはこの内容は明言されていないのですが、以前のtidyeval資料にはそういった記述があります。 メタプログラミングが何の得になるかというと、dplyrやggplot2のようにユーザー視点で使いやすい関数を実装できるようになる、というのが一番の恩恵だと思います

つまり、パッケージ開発者を始めとするRプログラミングのガチ勢ならtidyevalは全て知っておいて頂きたいですが、一般的なRユーザーはtidyevalを全部知る必要は無いと思います(私見)

Info

ただし一般ユーザーと言えど、tidyevalをフル活用したdplyr関数を自作関数に組み込んだりしたら、メタプログラミングに片足突っ込んでます。この記事で説明する内容はこの辺の人向けに書いてます。

rlangは試行錯誤の末、2022年にようやくversion1.0.0リリースを迎えた

rlangパッケージはtidyevalの概念を体現するものであり、r-libの一つのパッケージです。

2016年にHadley Wickham氏による”lazy”パッケージとしての開発が始まり、8年の歳月をかけてメジャーバージョン1.0.0のリリースに至りました🎉

メジャーリリースに至るまで紆余曲折があったらしく、用語や関数が名称変更したり、機能変更されてきたりしました。

以前にはlazyevalUQ()といった名称や関数がありましたが、今は使用されていません。注意しましょう。

ステップ1 expressionとevaluationを理解する

Non-standard evaluation(非標準評価)

tidyevalの原理を理解する前に、「Non-standard evaluation(非標準評価)」という、少し変わった値の評価方法があることを知っておきましょう💡

もう一度library()関数を見てみます。

MASSという変数はありませんが、この関数ではMASSがパッケージ名であると解釈し"MASS"に変換しています。

# baseRに存在するNSEの例

library(MASS)
##  Works
MASS
##  エラー:  オブジェクト 'MASS' がありません 

rlangパッケージの関数を使えば、似たような機能を持つ関数を自作することができます

たとえば、paste()関数のNSE版を作ってみることにします。

paste()を使うと文字列を結合して文章を作ることができますよね。
しかし、全ての要素にダブルクォーテーションマークを付けるのが面倒になってきたので、library()関数のようにダブルクォーテーションを必要としない関数に変えようと思いました。

paste("Good", "morning", "Snitch")
## [1] "Good morning Snitch"

rlangの関数を使って作られたのが以下です。

paste_nse <- function(...) {
    args <- ensyms(...)
    paste(purrr::map(args, rlang::as_string), collapse = " ")
}

paste_nse(Good, morning, Snitch)
## [1] "Good morning Snitch"

"を使わなくても全く同じ機能が実現できました!

ここではGoodmorningを変数名のように与えながら、実際は変数として見ていないことになります。このような特殊な状態をquotedと表現し、この特殊な状態を、専用の特殊な方法で評価することをnon-standard evaluation(NSE)と呼びます。

注意

`ensyms()`等の関数がなんなのかについては次回の記事にて紹介していきます。今回はあくまで「雰囲気理解」😙で!

評価直前で止めるということ ~ expressionとevaluation

ここからは詳しいコードの説明も交えながらNSEを説明したいと思います。

先ほどのNSEでは”quoted”な状況が作られてから特殊な評価をしていると説明しましたよね。この”quote”してから”evaluation”する方法の中で、最もシンプルなのはexpressionを作ってからevaluationすることです。

一例として最もシンプルであろう、expr()eval()を見てみましょう。

library(rlang)

expr_pi <- expr(pi * 100)
expr_pi
## pi * 100

eval(expr_pi)
## [1] 314.1593

expr()は与えられたRコードを評価待ちの状態(quoted)にする関数です。複雑なRコードも与えることができま。

library(tidyverse)
library(rlang)

rlang::expr(
    starwars %>%
        group_by(species) %>%
        summarise(mean_height = mean(height))
)

## starwars %>% group_by(species) %>% summarise(mean_height = mean(height))

実際にはexpr()eval()の組み合わせが全てではないのですが、評価待ち(quoted)にしてからevaluationするという方針は今後も共通しています

Info

余談ですが、`expr()`を使うとシンタックスシュガーが解除されます。これは、expressionが後述の構文木解析された後の状態だからです。

“`r
rlang::expr(
starwars |>
group_by(species) |>
summarise(mean_height = mean(height))
)

## summarise(group_by(starwars, species), mean_height = mean(height))
“`

expr()で作られたオブジェクトは評価待ちの状態にありますが、eval()関数により評価されるまでは値の計算が行われません

まずは、「Rコードをのものをオブジェクトとして保存しておいて、後で評価することが可能」というポイントを押さえましょう🖐️

expr()で作られるものはexpressionである

先ほどexpr()関数で何やら評価直前の謎のオブジェクトが出来ました。ありゃ一体何だ🤔?

結論から言うと、`expr()`で作られたものは"expression"と呼ばれるものです。

これまで見てきたとおり、expressionは評価直前で止められたオブジェクトですが、実態はRコードをリストに落とし込んだデータ構造です➰😲⁉️

なんと、リストと全く同じ様に参照・置換が可能です

my_function_call <- expr(my_function(x = a, y = b, na.rm = TRUE))

my_function_call
## my_function(x = a, y = b, na.rm = TRUE)

my_function_call[[1]]
## [1] my_function

my_function_call[["x"]]
## [1] a

my_function_call[["na.rm"]]
## [1] TRUE

my_function_call$y = "new_strings"
my_function_call
## my_function(x = a, y = "new_strings", na.rm = TRUE)

my_function_call$z = expr(df)
my_function_call
## my_function(x = a, y = "new_strings", na.rm = TRUE, z = df)

ご覧の通り、expressionはリストと同様実に様々な操作が可能であり、Rプログラミングはプログラミングすることができることがよく分かります(哲学)

Info

とは言え、今回のようにリスト操作でexpressionを変更して評価する機会はそんなにないんじゃないか?🤔と思います。

ここでは「expressionがとても特別なオブジェクトである」という事がお伝えしたいポイントでした。

コラム 抽象構文木
「抽象構文木(AST: Abstract syntax tree)」とは、言語をツリー表現に落とし込んだものであり、プログラミング言語では処理構文を理解するのに使われたりします。私の知っているところだと、tree-sitterはASTを解析してシンタックスハイライトを実現してたと思います。

ASTはRでも簡単に確認することができます。lobstrパッケージのast()関数を使います。

    
lobstr::ast(my_function(x = a, y = b, na.rm = TRUE))
## █─my_function 
## ├─x = a 
## ├─y = b 
## └─na.rm = TRUE 
# 実際には結果がカラフルに出力されます
    

ast()関数を使うと、expressionの演算順番を確認することもできますし、後述のunquoteについてもデータの確認に便利です👀

今回はexpressionを深掘りしないのでast()関数はコラム程度にしておきますが、作成したexpressionが想定通りのものになっているかを確認したい時など、理解の補足情報として使ってみてください。

なお、ast()に登場するパーツは三種類があります。これらはexpressionを構成する単位です。
①constants(数値や文字列といった定数)、②symbols(オブジェクトそのもの)、③call(コード式のまとまり)がそれですが、詳しくはAdvanced Rを参照ください。

関数内でexpressionを使うときは一手間必要

先ほどのexpr()関数は関数外で動作確認しましたが、自作関数内で実装するにはどうしたら良いでしょう?

例えば、シンプルに引数を受け取ってからexpressionを返す関数capture_it()を作ってみます。
a + bをキャプチャさせようとしていますが、残念ながらうまくいきません😕

capture_it <- function(x) {
    expr(x)
}
capture_it(a + b)
## x

expr()は与えられたexpressionを愚直に返すため、引数でexpressionを変更できないためです。

引数を使ったexpressionを関数に組み込む場合は「変数を引数で置き換える」行為がワンステップ挟まれるため、これまでのやり方に一手間入るイメージになります。

先ほどの例でいうと、xを一度変数として評価してからexpressionに変換する関数rlang::enexpr()が用意されていますので、これを使うとうまくいきます。

capture_this <- function(x) {
    enexpr(x)
}
capture_this(a + b + c)
## a + b + c
Info

もうここでexpr()??enexpr()????と混乱しそうですが、これらの関数の違いは置いときましょう。次回説明します。

ここで強調しておきたいのは、自作関数にNSEを組み込む場合、関数の外でやる場合より一手間増えますよということです。

ステップ2 quasiquotationにおけるunquoteを知る

!!バンバンでunquoteする

これまでに、「評価を一回中途半端にとめた状態であるexpressionを作ってから、それを評価する」というこのを紹介してきました。

冒頭軽く述べましたが、この状況に置かれたものを”quotation”されている(=quoted)と呼びました。

では、expr()関数が自由に引数をとれるようにするにはどうしたら良いでしょうか?さっきの例でも書いたとおり、expr()は与えられた通りのコードをexpressionにしてしまいます。

実は、「expr()に与える変数を部分的に評価する」仕組みはbaseRには存在しないそうです。これを解決するために、tidyevalは薄皮一枚剥ぐように、変数を一回評価する仕組みを導入しています。`!!`(bang-bang)です。

capture_it <- function(x) {
    expr(x)
}
capture_it(x = 100)
## x

capture_this <- function(x) {
    expr(!!x)
}
capture_this(x = 100)
## 100

最初の関数capture_it()はさっきと同じ関数です。xを関数内でそのまま評価しており、xが引数のxと認識されずに与えられたままをexpressionにします

一方capture_this()!!xを関数内に与えています。`!!`は"bang-bang"と呼称し、unquoteを担う機能です。

イメージ的には、変数を薄皮一枚剥ぐように部分的に評価する感じです。

!!があることで、expression -> evaluation’変数評価->expression->evaluation’という風に変化します。変数評価の余地がある分、関数設計にも柔軟性がでてきます。

quotationは評価待ちのexpresssssionですが、!!によるunquote自体がquotationには存在しない概念であり、quotationとは違う手順です。そこで、`!!`などを使って一部評価する手法はquotationとは異なり、quasiquotation(疑似quotation)という名前が付けられています

Info

初心者つまづきポイントで、quotationとquasiquotationという用語がややこしいかと思います。結局はquasiquotationを意識すること無く使う感じ二なると思うので、ここの用語にはこだわらなくて大丈夫です。

ステップ3 Quosureを理解する

ここまでに、expressionとevaluation、それから薄皮一枚剥いで評価する!!によるunquoteを紹介してきました。

これらはまさしくtidyevalの核となる考え方ですが、tidyeval三本柱の一本目💈でしかありません。

もう二本は”quosure”に関わる話です。quosureの情報まで揃って初めて、dplyrやggplot2関数がどういう原理で変数を評価しているのかが分かるようになります

まずはenvironmentをなんとなく理解する

environment(環境)は使われる場面・プログラミング言語によって色々と意味が変わりますが、Rでは明確な意味を持つ言葉です。

Environmentという概念はオブジェクトの存在する”環境のことであり、グローバル・パッケージ・関数・・・といった様々な環境が層を成しています。

オブジェクトを呼び出す(callする)とき、ある優先順位に従って順々にオブジェクトの場所を探していきます。environmentの一覧はsearch()関数で見ることが出来ます。

search()

##  [1] ".GlobalEnv"        "package:dplyr"     "package:rlang"    
##  [4] "package:stats"     "package:graphics"  "package:grDevices"
##  [7] "package:utils"     "package:datasets"  "package:methods"  
## [10] "Autoloads"         "org:r-lib"         "package:base"

rlang::env_get()を使うと、変数が存在するenvironmentを指定することもできます。

myvar <- "variable"

rlang::env_get(.GlobalEnv, "myvar")
## [1] "variable"

パッケージにおいて定義された変数(または関数)は、そのパッケージのenvironmenの中に存在しています。

dplyr::filter

## function (.data, ..., .preserve = FALSE) 
## {
##     UseMethod("filter")
## }
## <bytecode: 0x6848d10>
## <environment: namespace:dplyr>

このように、変数(または関数)はそれぞれのenvironmentに存在しており、そのenvironmentを呼び出さない限り変数にもアクセスできません。そのため、「関数の中は関数のenvironmentなのでグローバルenvironmentからは見ることが出来ない」などといったことが起こります。

Quosureでexpressionにenvironmentを同梱する

“Quosure”の定義がtidyeval三本柱の二本目💈💈です。これがdplyrやggplot2の関数理解への一歩になります。

"Quosure"(Quote + Enclosure)は、これまでに何度も使ってきた"expression"に"environment"を追加したモノです。

environmentはrlang::env()rlang::new_environment()を使って作成することができます。

myenv <- env(a = 1, b = 2)
myenv
## <environment: 0x514ab88>

myenv$a
## [1] 1

my_quosure <- new_quosure(expr(a + b), myenv)
my_quosure

## <quosure>
## expr: ^a + b
## env:  0x55b0417dc200

quosureの凄いところは、expressionをenvironment情報とともに評価できる点です。この時にeval()を使ってはただのexpression評価になっちゃうので、rlang::eval_tidy()と使います。(やっと本質っぽい関数名がでてきた…!?)

eval_tidy(my_quosure)
## [1] 3

以上の、「environmentと一緒にexpressionをevaluationする」ことができるのが”quosure”であり、tidyevalの三本柱の二本目とのことでした。

確かに便利そうだけど、一体どんな意味が…?🤔 という感じですが、このquosureをさらに拡張することでggplot2のアレが実現されています。

data-maskで評価する

data-maskがtidyevalの三本柱、三本目💈💈💈です。

一言で言えば、データフレーム等のデータをquosureのenvironmentに使ってしまおうという試みがdata-maskです。

以下はdplyr::filter()関数を使ったシンプルな処理です。この処理はheightという変数がstarwarsデータの文脈で評価されている、と解釈できます。

filter(starwars, height > 180)

# # A tibble: 38 × 14
#    name         height  mass hair_color skin_color eye_color
#    <chr>         <int> <dbl> <chr>      <chr>      <chr>    
#  1 Darth Vader     202 136   none       white      yellow   
#  2 Biggs Darkl…    183  84   black      light      brown    
#  3 Obi-Wan Ken…    182  77   auburn, w… fair       blue-gray
#  4 Anakin Skyw…    188  84   blond      fair       blue     
#  5 Chewbacca       228 112   brown      unknown    blue     
#  6 Boba Fett       183  78.2 black      fair       brown    
#  7 IG-88           200 140   none       metal      red      
#  8 Bossk           190 113   none       green      red      
#  9 Qui-Gon Jinn    193  89   brown      fair       blue     
# 10 Nute Gunray     191  90   none       mottled g… red      
# # … with 28 more rows, and 8 more variables:
# #   birth_year <dbl>, sex <chr>, gender <chr>,
# #   homeworld <chr>, species <chr>, films <list>,
# #   vehicles <list>, starships <list>

実際、filter関数の定義を覗いてみるとcaller_env()で実行環境を取り込んだオブジェクトになっていることが分かります。ここで使われているcaller_env()starwarsデータをenvironmentに置き換える処理を行っています

getS3method("filter", "data.frame")

## function (.data, ..., .preserve = FALSE) 
## {
##     loc <- filter_rows(.data, ..., caller_env = caller_env())
##     dplyr_row_slice(.data, loc, preserve = .preserve)
## }
## <bytecode: 0x55b04217f1e8>
## <environment: namespace:dplyr>
Info

関数の定義を見たい時、関数名だけを打てば見ることが出来ます。しかし、S3メソッドとしてを呼んでいる場合にはgetS3method()を使わなければならない点に注意です。

tidy_eval()は与えられた環境を最優先して評価するため、環境でデータをマスクする、ということでdata-maskと名付けられています。

# グローバル環境に変数として"var"を定義
var <- "long long strings"

myenv <- env(var = "short word")
eval_tidy(expr(var), new_data_mask(myenv))

## [1] "short word"
### 環境データが優先される
Info

実は、先ほどのようにenv()を第二引数に与えるのはdeprecated(非推奨)となっています。今はnew_data_mask()を使用することが推奨されていますので、サンプルコードでは推奨されている書き方を使いました。

…と言いつつも、ライトユーザーならおそらくeval_tidy()を使うことはほとんどなく、実際に評価が行われるのはdplyr等のパッケージ関数だと思いますので、深く考えなくてもOK。

まとめ

過去最高に長い記事になってしまったので、これまでの話をちゃんとまとめてみたいと思います。

expressionをevaluationするということ

最初に、 変数をすぐに評価せずにexpressionで止めるというquoteについて説明しました。

expressionは特殊なオブジェクトであり、evaluation待ちの状態。quotedとも呼びました。あれこれと変数を部分的に評価してからexpressionを作成し、あとでevaluationする、という流れが基本的なポイントでした。

!!でunquoteできるのがquasiquotation

expressionはexpr()で作ることが出来ますが、基本的には書いたとおりのexpressionしか作れません。!!(bang-bang)で変数を一回評価してからexpressionに挿入する、というテクニックによりexpressionの定義方法に柔軟性がもたらされました。

「変数を一回評価する」という部分はunquoteと言い、tidyevalがbaseRには無い機能として定義した"quasiquotation"の仕組みです。 のでした。

次回予告

ということで、今回はrlangパッケージが提供するtidyevalについてなんとなく原理を理解しよう!の記事をお送りしました👋

次回はケーススタディ的に、どういう場合にはどういうtidyevalをするのかまとめてみようと思います!! ではまた~😷

脚注

脚注
1 「コードそのものを操作したり変更したりするプログラミング」をメタプログラミングと言います。
2 今のAdvanced Rにはこの内容は明言されていないのですが、以前のtidyeval資料にはそういった記述があります。

コメント

タイトルとURLをコピーしました