Rで作る対称コルーチン

カテゴリ: R

n月刊ラムダノート Vol.1の『「コルーチン」とは何だったのか』を読んでいる。せっかくなので勉強がてら、Rでコルーチンを実装してみることにした。今回は元祖コルーチンとして紹介されている対称コルーチンを扱う。

対称コルーチンでは、関数ABが互いを関数中で呼び合い、未実行な部分から次に相手を呼び出すところまでを順次実行していくものらしい。 n月刊ラムダノート Vol.1には疑似コードも含めて紹介されているので、詳しくはそちらを参照して欲しい。

本記事では、

  • 書中に紹介された疑似コードをRで実装
  • 疑似コードがわざと抱えているバグを修正
  • コルーチンを簡単に書けるようにするメタプログラミング

の3本仕立てでお送りする。 Rでコルーチンが活躍する場面はほとんどないだろう。 Shinyには非同期プログラミングの需要があるが、こちらではfuturepromiseといったパッケージを利用した方が良いだろう (Shinyユーザのための非同期プログラミング入門 by hoxo_m氏)。

ラムダノートの疑似コードをRに実装

さて、n月刊ラムダノート Vol.1にて紹介されている疑似コードをRで実装してみよう。関数ABのどこまでを実行したか管理するために、環境をiに割り当てた。 i$Ai$BNULL (既定値) の時は、これらの値を1に書き換えた上で前半の処理を実行する。 NULL以外の時は後半の処理を実行する。

i <- new.env()

A <- function() {
  # 初回のみ実行
  if (is.null(i$A)) {
    print("A-start") # 1回目の処理
    i$A <- 1 # 1回目の処理を完了したフラグ
    return(B()) # コルーチン呼び出し
  }
  
  # 二回目のみ実行
  print("A-end")
}

# Aと同様に実装
B <- function() {
  if (is.null(i$B)) {
    print("B-start")
    i$B <- 1
    return(A())
  }
  print("B-end")
}

A()
## [1] "A-start"
## [1] "B-start"
## [1] "A-end"

ラムダノート上でも指摘されている通り、中断したコルーチンを再開し忘れているので、"B-end"が出力されない。

また、ルーチン終了時に実行状況を初期化していないため、もう一度コルーチンを走らせると、"A-end"のみが出力されてしまう。

A()
## [1] "A-end"

修正版の実装

上述のバグを修正するとやや複雑なコードが生まれる。ポイントは2点

  • ルーチンの最初に、ルーチン末尾まで実行済みであるかを評価するコードを挿入する
    • 末尾まで実行済みであれば、実行状況を初期化した上でコルーチンを終了する
  • ルーチン末尾の処理が終わったら、コルーチンを呼び出す
i <- new.env()

A <- function() {
  # ルーチンの最後まで実行済みか確認
  if (identical(i$A, 0)) {
    i$A <- i$B <- NULL # ルーチンの実行状況を初期化
    return(invisible()) # 何も出力せずに終了
  }
  
  # 初回のみ実行
  if (is.null(i$A)) {
    print("A-start") # 1回目の処理内容
    i$A <- 1 # 1回目の処理を完了したフラグ
    return(B()) # コルーチン呼び出し
  }
  
  # 2回目に実行
  ## 今回は3回目のルーチン呼び出しがないため、
  ## i$Aを0に変更し、全処理が実施済みとのフラグを立てる
  i$A <- 0
  print("A-end")
  B()
}

# Aと同様に実装
B <- function() {
  if (identical(i$B, 0)) {
    i$A <- i$B <- NULL
    return(invisible())
  }
  if (is.null(i$B)) {
    print("B-start")
    i$B <- 1
    return(A())
  }
  i$B <- 0
  print("B-end")
  A()
}

A()
## [1] "A-start"
## [1] "B-start"
## [1] "A-end"
## [1] "B-end"

見事、"B-end"まで出力された。

A()
## [1] "A-start"
## [1] "B-start"
## [1] "A-end"
## [1] "B-end"

一般化

コルーチン内の処理の数が増えるごとに、if文を書き連ねていくのは地獄だ。そこで、コルーチンを生成する関数coroutineを書いてみよう。 coroutine関数の第一引数は、自身の名前、第二引数は呼び出すルーチンの名前、第三引数以降は呼び出しごとに実行したい処理の内容とする。処理内容は任意の個数書けるように、省略記号...を用いる。 rlang::enquos関数を用いると、省略記号に指定した処理を表現式として保存しておくことができる。後は今が何度目のルーチン呼び出しかを管理し、i番目であれば、省略記号に指定したi番目の処理をrlang::eval_tidyによって実行すれば良い。

i <- new.env()

coroutine <- function(self, resume, ...) {
  yield <- rlang::enquos(...)
  i[[self]] <- 1L
  n <- length(yield) # 最大処理回数

  function() {
    # 全ての処理が完了している場合の操作
    if (i[[self]] > n) {
      i[[self]] <- i[[resume]] <- 1L
      return(invisible())
    }
    # 今回の処理の実行
    rlang::eval_tidy(yield[[i[[self]]]])
    # 実行状況の更新
    i[[self]] <- i[[self]] + 1L
    # コルーチン呼び出し
    match.fun(resume)()
  }
}

coroutine関数を使ってラムダノートの例にあったコルーチンを実装してみよう。

A <- coroutine("A", "B", print("A_start"), print("A_end"))
B <- coroutine("B", "A", print("B_start"), print("B_end"))
A()
## [1] "A_start"
## [1] "B_start"
## [1] "A_end"
## [1] "B_end"

iは適切に初期化されているので、二度目以降の呼出にも対応する。

A()
## [1] "A_start"
## [1] "B_start"
## [1] "A_end"
## [1] "B_end"