デザインのトレードオフ

Hadley Wickham

Lionel Henry

magrittr がパイプを実装するには、さまざまな方法があります。 このドキュメントの目的は、そのバリエーションと、各アプローチの様々な長所と短所を解明することです。 このドキュメントは主に magrittr の開発者を対象としていますが(重要な検討事項を忘れないように)、 パイプをもっと理解したい人や、さまざまなトレードオフを実現する独自のパイプを作りたい人にとっても興味深いものになるでしょう。

コード変換

Rの基本的な表現でパイプラインを変換する方法には、3つの主要なオプションがあります。 ここでは、x %>% foo() %>% bar() で説明しています。

ここではまず、パイプに求められる特性を探り、3つのバリエーションのそれぞれの特性を見ていきます。

希望する特性

これらは、パイプが持つべき特性であり、大まかには重要度の高いものから低いものの順に並んでいます。

少し気をつけるだけで、すべての実装で適切な可視性と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

設計上の決定事項の意味合い

いくつかのプロパティは、高レベルの設計上の決定事項を直接反映しています。

プレースホルダーの結合

入れ子になったパイプでは、パイプされた式をプレースホルダーに割り当てません。 他のバリアントではこの割り当てを行います。 つまり、入れ子型の書き換えアプローチでは、パイプ式が複数回ペーストされない限り、複数のプレースホルダーを持つことはできません。 これは、有害な効果を持つ複数の評価を引き起こすことになります。

sample(10) %>% list(., .)

# Becomes
list(sample(10), sample(10))

引数の中のプレースホルダーに代入すれば、入れ子になっていても怠惰さを保つことができます。 しかし、第1引数が第2引数よりも先に評価されるという保証はないため、正しく動作しません。

sample(10) %>% foo(., .)
foo(. <- sample(10), .)

これらの理由により、入れ子のパイプは複数のプレースホルダーをサポートしていません。 対照的に、他のすべてのバリアントでは、パイプ式の結果がプレースホルダに割り当てられます。 プレースホルダーバインディングの作成方法はさまざまですが (lazy に作成するか、Eager に作成するか、mask 内に作成するか、現在の環境に作成するか、番号付きのシンボルに作成するか、ユニークなシンボルに作成するか)、 これらのすべてのバリアントでは複数のプレースホルダーを使用できます。

マスキング環境

パイプのローカルなバリアントは、マスク内で評価されるため、字句の効果や連続したスタックを持ちません。これは、現在の環境で評価される字句のバリアントとは異なります。

怠惰

遅延バリアントとは異なり、反復評価で pipe を実装したすべての eager バージョンは遅延評価基準を通過しません。

次に、どの遅延バージョンもプログレッシブスタック基準を満たしません。構造上、遅延評価では、評価が始まる前にすべてのパイプ式をスタックにプッシュする必要があります。逆に、すべての eager variant はプログレッシブスタックを持っています。

番号付きプレースホルダー

番号付きプレースホルダーを使用するどのバージョンも、パイプされた値を イーガーでアンバインドすることはできません。これらの実装では、これらのバインディングの永続性をどのように実現しているのでしょうか。

3つの実装

GNU Rチームは、ベースRでの入れ子のアプローチを、パース時にコード変換して実装することを検討しています(パーサーによって -><- に変換されるのと同じです)。

magrittrでは3つのアプローチを実装しました。

これらのアプローチにはそれぞれ長所と短所があります。

入れ子のパイプ

`%|>%` <- magrittr::pipe_nested

複数のプレースホルダー ❌

入れ子のパイプは式をプレースホルダーにバインドしないため、複数のプレースホルダーをサポートできません。

"foo" %|>% list(., .)
#> Error: Can't use multiple placeholders.

遅延評価 ✅

引数の適用に関する通常のルールに依存しているため、入れ子のパイプは遅延しています。

{
  stop("oh no") %|>% try(silent = TRUE)
  "success"
}
#> [1] "success"

永続性とイーガーアンバインディング ✅

パイプ式は、各関数の実行環境内の約束事として結合されます。 この環境は、プロミスが保持している限り持続します。 プロミスが評価されると,環境への参照が破棄され,ガベージコレクションの対象となります.

例えば、関数を作成する関数ファクトリがあります。作成された関数は、作成時に与えられた値を返します。

factory <- function(x) function() x
fn <- factory(TRUE)
fn()
#> [1] TRUE

これにより、入れ子になったパイプに問題が生じることはありません。:

fn <- TRUE %|>% factory()
fn()

プログレッシブスタック ❌

パイプライン化された式は遅延的に評価されるため,実行開始前にパイプライン全体がスタックにプッシュされます.その結果,必要以上に複雑なバックトレースが発生します.

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の評価ルールを使用した場合の利点です。副作用は正しい環境で発生します。

foo <- FALSE
TRUE %|>% assign("foo", .)
foo
#> [1] TRUE

コントロールフローは正しい動作をします。:

fn <- function() {
  TRUE %|>% return()
  FALSE
}
fn()
#> [1] TRUE

連続スタック ✅

評価は現在の環境で行われるため、スタックは連続しています。 これが何を意味するのか、構造化されたバックトレースでエラーを計測してみましょう。

options(error = rlang::entrace)

バックトレースのツリー表示は、実行フレームの階層を正しく表しています。

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()

Eager lexical pipe

`%!>%` <- magrittr::pipe_eager_lexical

複数のプレースホルダー ✅

パイプ式はプレースホルダーに熱心に割り当てられます。 これにより、複数の評価を行うことなく、プレースホルダーを複数回使用することが可能になります。

"foo" %!>% list(., .)

lazy 評価 ❌

割り当てにより、各ステップの評価が熱心に行われます。

{
  stop("oh no") %!>% try(silent = TRUE)
  "success"
}
#> Error in stop("oh no") %!>% try(silent = TRUE): oh no

Persistence: r fail()`

各ステップで . の値を更新しているため、パイプされた式は永続的ではありません。 これは、パイプされた式がすぐに評価されない場合に微妙な影響を与えます。

イーガーパイプでは、構築された関数をパイプラインの途中で呼び出そうとすると、ファクトリー関数でかなり混乱した結果が得られます。 次のスニペットでは、プレースホルダー . は、初期値 TRUE ではなく、構築された関数自体にバインドされており、関数が呼び出されます。

fn <- TRUE %!>% factory() %!>% { .() }
fn()
#> function() x
#> <bytecode: 0x7f88479e2a98>
#> <environment: 0x7f88459aa8a8>

また、現在の環境では . をバインドしているので、パイプラインが戻ってきたらそれをクリーンアップする必要があります。 その時点で、プレースホルダーはもう存在しません。

fn <- TRUE %!>% factory()
fn()
#> Error in fn():  オブジェクト '.' がありません

または、以前の値にリセットされています(ある場合)。

. <- "wrong"
fn <- TRUE %!>% factory()
fn()
#> [1] "wrong"

イーガー・アンバインディング。✅

これは、各ステップでプレースホルダーの値を更新することの裏返しです。以前の中間的な値をすぐに集めることができます。

プログレッシブ・スタック : ✅

パイプ式は来るたびに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()

語彙効果と連続スタック:✅

マスクではなく現在の環境で評価することで、正しい副作用が得られます。

foo <- FALSE
NA %!>% { foo <- TRUE; . }
#> [1] NA

foo
#> [1] TRUE
fn <- function() {
  TRUE %!>% return()

  FALSE
}
fn()
#> [1] TRUE

lazy マスキングパイプ

`%?>%` <- magrittr::pipe_lazy_masking

複数のプレースホルダー ✅

パイプ式は遅延的にプレースホルダーに割り当てられます。 これにより、複数の評価を行うことなく、プレースホルダーを複数回使用することができます。

"foo" %?>% list(., .)
#> [[1]]
#> [1] "foo"
#> 
#> [[2]]
#> [1] "foo"

lazy 評価 ✅

引数は、delayedAssign()で割り当てられ、遅延評価されます。:

{
  stop("oh no") %?>% try(silent = TRUE)
  "success"
}
#> [1] "success"

Persistence: ✅

遅延マスキングパイプでは、パイプ式ごとに1つのマスキング環境を使用します。これにより、中間値の永続化や順序のない評価が可能になります。例えば、ファクトリー関数は期待通りに動作します。

fn <- TRUE %?>% factory()
fn()
#> [1] TRUE

Eager unbinding: ✅

1つのパイプ式に対して1つのマスク環境を使用しているため、中間値が不要になったらすぐに回収することができます。

Progressive stack: ❌

遅延パイプでは、評価の前にパイプライン全体がスタックにプッシュされます。

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()

しかし、プレースホルダーのおかげで、バックトレースはネストしたパイプのアプローチよりも混乱していないことに注意してください。

Lexical effects ❌

遅延パイプはマスクで評価されます。そのため、不正な環境で字句の副作用が発生します。

foo <- FALSE
TRUE %?>% assign("foo", .)
foo
#> [1] FALSE

return() 関数のようなスタックセンシティブな関数は,適切なフレーム環境を見つけることができません。

fn <- function() {
  TRUE %?>% return()
  FALSE
}
fn()
#> [1] FALSE

連続スタック ❌

マスキング環境では、不連続なスタックツリーが発生します。:

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()