magrittr がパイプを実装するには、さまざまな方法があります。 このドキュメントの目的は、そのバリエーションと、各アプローチの様々な長所と短所を解明することです。 このドキュメントは主に magrittr の開発者を対象としていますが(重要な検討事項を忘れないように)、 パイプをもっと理解したい人や、さまざまなトレードオフを実現する独自のパイプを作りたい人にとっても興味深いものになるでしょう。
Rの基本的な表現でパイプラインを変換する方法には、3つの主要なオプションがあります。 ここでは、x %>% foo() %>% bar()
で説明しています。
ネストされた。
Eager (mask)、マスキング環境
これは基本的に、magrittr 2.0以前に %>%
がどのように実装されていたかを示しています。
Eager (mask-num): マスキング環境、ナンバリングプレースホルダ
Eager (lexical): 語彙的環境
このバリアントでは、現在の環境のプレースホルダー .
にパイプ式を割り当てます。 この割り当ては一時的なもので、パイプが戻ってくると、プレースホルダのバインディングは以前の状態にリセットされます。
Lazy (mask): マスキング環境
Lazy (mask-num): マスキング環境、ナンバリングプレースホルダ
Lazy (lexical-num): 語彙的環境、番号付きプレースホルダー
ここではまず、パイプに求められる特性を探り、3つのバリエーションのそれぞれの特性を見ていきます。
これらは、パイプが持つべき特性であり、大まかには重要度の高いものから低いものの順に並んでいます。
Visibility: パイプ内の最終関数の可視性を維持する必要があります。 これは、サイドエフェクト関数(通常、最初の引数を見えないように返す)で終わるパイプが印刷されないようにするために重要です。
複数のプレースホルダー: パイプの各コンポーネントは、複数のプレースホルダーがある場合でも、一度だけ評価されるべきで、 sample(10) %>% cbind(., .)
は、同じ値を持つ2つの列を生成します。 関連して、sample(10) %T>% print() %T>% print()
は、同じ値を2回出力しなければなりません。
遅延評価: パイプのステップは、実際に必要なときにのみ評価されます。 これは、パイプが stop("!") %>% try()
のようなコードを扱えることを意味しており、パイプがより広範囲のR式を捉えることができるようになるため、便利なプロパティです。
一方で、これは意外な効果をもたらすかもしれません。 例えば、警告を抑制する関数をパイプラインの最後に追加した場合、抑制はパイプライン全体に適用されます。
パイプされた値の永続性: 引数は必ずしもパイプされた関数によってすぐに評価されるとは限りません。 例えば、関数ファクトリがパイプされた場合など、パイプラインが戻ってきてからずっと評価されることもあります。 パイピングされた値が永続的であれば、構築された関数はいつでも呼び出すことができます。
Refcount neutrality: パイプラインの戻り値は、参照カウントが1でなければなりません。そうすれば、次の操作でその場で変異させることができます。
Eager unbinding: パイプは大きなデータオブジェクトを扱うことが多いので、パイプライン内の中間オブジェクトはできるだけ早くアンバウンドして、ガベージコレクションにかけられるようにしておく必要があります。
Progressive stack: パイプを使用する場合は、コールスタックに追加するエントリをできるだけ少なくして、traceback()
が最大限に役立つようにする必要があります。
Lexical side effects: 副作用は、現在の語彙環境で発生するはずです。これにより、NA %>% { foo <- . }
は、現在の環境でパイプされた値を割り当て、NA %>% { return(.) }
は、パイプラインを含む関数から戻ります。
Continuous stack: パイプが親フレームのチェーンに影響を与えてはいけません。これは、コールスタックのツリー表現において重要です。
少し気をつけるだけで、すべての実装で適切な可視性とrefcountsへの中立的な影響を持たせることができるので、ここでは他のプロパティのみを検討します。:
Nested | Eager (mask) |
Eager (mask-num) |
Eager (lexical) |
Lazy (mask) |
Lazy (mask-num) |
Lazy (lexical-num) |
|
---|---|---|---|---|---|---|---|
Multiple placeholders | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Lazy evaluation | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
Persistence | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
Eager unbinding | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ |
Progressive stack | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Lexical effects | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
Continuous stack | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
いくつかのプロパティは、高レベルの設計上の決定事項を直接反映しています。
入れ子になったパイプでは、パイプされた式をプレースホルダーに割り当てません。 他のバリアントではこの割り当てを行います。 つまり、入れ子型の書き換えアプローチでは、パイプ式が複数回ペーストされない限り、複数のプレースホルダーを持つことはできません。 これは、有害な効果を持つ複数の評価を引き起こすことになります。
引数の中のプレースホルダーに代入すれば、入れ子になっていても怠惰さを保つことができます。 しかし、第1引数が第2引数よりも先に評価されるという保証はないため、正しく動作しません。
これらの理由により、入れ子のパイプは複数のプレースホルダーをサポートしていません。 対照的に、他のすべてのバリアントでは、パイプ式の結果がプレースホルダに割り当てられます。 プレースホルダーバインディングの作成方法はさまざまですが (lazy に作成するか、Eager に作成するか、mask 内に作成するか、現在の環境に作成するか、番号付きのシンボルに作成するか、ユニークなシンボルに作成するか)、 これらのすべてのバリアントでは複数のプレースホルダーを使用できます。
パイプのローカルなバリアントは、マスク内で評価されるため、字句の効果や連続したスタックを持ちません。これは、現在の環境で評価される字句のバリアントとは異なります。
遅延バリアントとは異なり、反復評価で pipe を実装したすべての eager バージョンは遅延評価基準を通過しません。
次に、どの遅延バージョンもプログレッシブスタック基準を満たしません。構造上、遅延評価では、評価が始まる前にすべてのパイプ式をスタックにプッシュする必要があります。逆に、すべての eager variant はプログレッシブスタックを持っています。
番号付きプレースホルダーを使用するどのバージョンも、パイプされた値を イーガーでアンバインドすることはできません。これらの実装では、これらのバインディングの永続性をどのように実現しているのでしょうか。
GNU Rチームは、ベースRでの入れ子のアプローチを、パース時にコード変換して実装することを検討しています(パーサーによって ->
が <-
に変換されるのと同じです)。
magrittrでは3つのアプローチを実装しました。
これらのアプローチにはそれぞれ長所と短所があります。
入れ子のパイプは式をプレースホルダーにバインドしないため、複数のプレースホルダーをサポートできません。
引数の適用に関する通常のルールに依存しているため、入れ子のパイプは遅延しています。
パイプ式は、各関数の実行環境内の約束事として結合されます。 この環境は、プロミスが保持している限り持続します。 プロミスが評価されると,環境への参照が破棄され,ガベージコレクションの対象となります.
例えば、関数を作成する関数ファクトリがあります。作成された関数は、作成時に与えられた値を返します。
これにより、入れ子になったパイプに問題が生じることはありません。:
パイプライン化された式は遅延的に評価されるため,実行開始前にパイプライン全体がスタックにプッシュされます.その結果,必要以上に複雑なバックトレースが発生します.
faulty <- function() stop("tilt")
f <- function(x) x + 1
g <- function(x) x + 2
h <- function(x) x + 3
faulty() %|>% f() %|>% g() %|>% h()
#> Error in faulty() : tilt
traceback()
#> 7: stop("tilt")
#> 6: faulty()
#> 5: f(faulty())
#> 4: g(f(faulty()))
#> 3: h(g(f(faulty())))
#> 2: .External2(magrittr_pipe) at pipe.R#181
#> 1: faulty() %|>% f() %|>% g() %|>% h()
また、バックトレースの表現が実際のコードと異なることにも注目してください。 これは、パイプラインを入れ子にして書き換えているためです。
これは、通常のRの評価ルールを使用した場合の利点です。副作用は正しい環境で発生します。
コントロールフローは正しい動作をします。:
評価は現在の環境で行われるため、スタックは連続しています。 これが何を意味するのか、構造化されたバックトレースでエラーを計測してみましょう。
バックトレースのツリー表示は、実行フレームの階層を正しく表しています。
foobar <- function(x) x %|>% quux()
quux <- function(x) x %|>% stop()
"tilt" %|>% foobar()
#> Error in x %|>% stop() : tilt
rlang::last_trace()
#> <error/rlang_error>
#> tilt
#> Backtrace:
#> █
#> 1. ├─"tilt" %|>% foobar()
#> 2. └─global::foobar("tilt")
#> 3. ├─x %|>% quux()
#> 4. └─global::quux(x)
#> 5. └─x %|>% stop()
パイプ式はプレースホルダーに熱心に割り当てられます。 これにより、複数の評価を行うことなく、プレースホルダーを複数回使用することが可能になります。
割り当てにより、各ステップの評価が熱心に行われます。
各ステップで .
の値を更新しているため、パイプされた式は永続的ではありません。 これは、パイプされた式がすぐに評価されない場合に微妙な影響を与えます。
イーガーパイプでは、構築された関数をパイプラインの途中で呼び出そうとすると、ファクトリー関数でかなり混乱した結果が得られます。 次のスニペットでは、プレースホルダー .
は、初期値 TRUE
ではなく、構築された関数自体にバインドされており、関数が呼び出されます。
fn <- TRUE %!>% factory() %!>% { .() }
fn()
#> function() x
#> <bytecode: 0x7f88479e2a98>
#> <environment: 0x7f88459aa8a8>
また、現在の環境では .
をバインドしているので、パイプラインが戻ってきたらそれをクリーンアップする必要があります。 その時点で、プレースホルダーはもう存在しません。
または、以前の値にリセットされています(ある場合)。
これは、各ステップでプレースホルダーの値を更新することの裏返しです。以前の中間的な値をすぐに集めることができます。
パイプ式は来るたびに1つずつ評価されるので、エラーが発生したときにはパイプラインの関連部分だけがスタックに載ります。:
faulty <- function() stop("tilt")
f <- function(x) x + 1
g <- function(x) x + 2
h <- function(x) x + 3
faulty() %!>% f() %!>% g() %!>% h()
#> Error in faulty() : tilt
traceback()
#> 4: stop("tilt")
#> 3: faulty()
#> 2: .External2(magrittr_pipe) at pipe.R#163
#> 1: faulty() %!>% f() %!>% g() %!>% h()
パイプ式は遅延的にプレースホルダーに割り当てられます。 これにより、複数の評価を行うことなく、プレースホルダーを複数回使用することができます。
引数は、delayedAssign()
で割り当てられ、遅延評価されます。:
遅延マスキングパイプでは、パイプ式ごとに1つのマスキング環境を使用します。これにより、中間値の永続化や順序のない評価が可能になります。例えば、ファクトリー関数は期待通りに動作します。
1つのパイプ式に対して1つのマスク環境を使用しているため、中間値が不要になったらすぐに回収することができます。
遅延パイプでは、評価の前にパイプライン全体がスタックにプッシュされます。
faulty <- function() stop("tilt")
f <- function(x) x + 1
g <- function(x) x + 2
h <- function(x) x + 3
faulty() %?>% f() %?>% g() %?>% h()
#> Error in faulty() : tilt
traceback()
#> 7: stop("tilt")
#> 6: faulty()
#> 5: f(.)
#> 4: g(.)
#> 3: h(.)
#> 2: .External2(magrittr_pipe) at pipe.R#174
#> 1: faulty() %?>% f() %?>% g() %?>% h()
しかし、プレースホルダーのおかげで、バックトレースはネストしたパイプのアプローチよりも混乱していないことに注意してください。
遅延パイプはマスクで評価されます。そのため、不正な環境で字句の副作用が発生します。
return()
関数のようなスタックセンシティブな関数は,適切なフレーム環境を見つけることができません。
マスキング環境では、不連続なスタックツリーが発生します。:
foobar <- function(x) x %?>% quux()
quux <- function(x) x %?>% stop()
"tilt" %?>% foobar()
#> Error in x %?>% stop() : tilt
rlang::last_trace()
#> <error/rlang_error>
#> tilt
#> Backtrace:
#> █
#> 1. ├─"tilt" %?>% foobar()
#> 2. ├─global::foobar(.)
#> 3. │ └─x %?>% quux()
#> 4. └─global::quux(.)
#> 5. └─x %?>% stop()