/* terashim.com */

システム開発・データエンジニアリング・データ分析についての個人的なノート

Rの条件ハンドリングを理解する

JavaやPythonなど、多くの言語には「例外ハンドリング」の仕組みが備わっています。Rにはこれに代わる仕組みとして 「条件ハンドリング」 があります。これについてコードを書いて実験しながら理解していきます。

エラー、警告、メッセージ

Rにはエラー、警告、メッセージを発生させる関数 stop()warning()message() があります。

stop() 関数を使うと、そこでエラーが発生します。

stop("エラー発生!")
Error in eval(expr, envir, enclos): エラー発生!

warning() 関数を使うと、警告メッセージが表示されます。

warning("警告!")
Warning: 警告!

message() 関数を使うとメッセージが表示されます。

message("メッセージ!")
メッセージ!

これらの関数はRユーザーにとっておなじみのものですが、実はその裏で条件ハンドリングの仕組みが動いています。

条件

Rでは エラー (error)警告 (warning)メッセージ (message) をまとめて 条件 (condition) と呼んでいます(実はそれ以外の条件もあります)。

条件を表すクラス

エラー警告メッセージは、それぞれS3クラス errorwarningmessage で表されます。 条件はこれらをまとめる親クラスconditionとして表されます。

simpleError()関数を使うと、errorクラスのオブジェクトを作成できます。

# errorオブジェクトを作成
error_object <- simpleError(message = "エラー!")

class()関数でこのオブジェクトのクラスを調べると、conditionクラス、errorクラス、simpleErrorクラスの順に継承されていることがわかります。

# S3クラスを確認
class(error_object)
[1] "simpleError" "error"       "condition"

errorオブジェクトからメッセージを取り出すには、conditionMessage関数を使います。

# errorオブジェクトからメッセージを取り出す
conditionMessage(error_object)
[1] "エラー!"

同様に、warningオブジェクトもsimpleWarning()関数で作成できます。

# warningオブジェクトを作成
warning_object <- simpleWarning(message = "警告!")
# S3クラスを確認
class(warning_object)
[1] "simpleWarning" "warning"       "condition"

これもconditionMessage()関数でメッセージを取り出せます。

# warningオブジェクトからメッセージを取り出す
conditionMessage(warning_object)
[1] "警告!"

同様にmessageオブジェクトもsimpleMessage()で作成し、conditionMessage()でメッセージ本文を取り出せます。

# messageオブジェクトを作成
message_object <- simpleMessage(message = "メッセージ!")
# S3クラスを確認
class(message_object)
[1] "simpleMessage" "message"       "condition"
# メッセージ本文を取り出す
conditionMessage(message_object)
[1] "メッセージ!"

シグナルを送る

signalCondition()関数を使うと、errorwarningmessageなど、条件オブジェクトの 「シグナルを送る」 ことができます。 しかし単にシグナルを送るだけでは何も起きません。特に、errorオブジェクトのシグナルを送ってもそこで処理は停止しません。

# errorオブジェクトのシグナルを送る
signalCondition(error_object)
NULL

条件ハンドリング

条件のシグナルが送られたとき、それに対処することを条件ハンドリングといいます。 条件ハンドリングを行う方法にはtryCatch()withCallingHandlers()の2種類があります。

tryCatch

tryCatch()はJavaやPythonなど多くの言語でおなじみの例外ハンドリングと類似の仕組みです。

RのtryCatch()関数は次のような引数を取ります。

tryCatch(expr, ..., finally)

引数exprに評価したい式を与え、...のところで名前付き引数errorを与えると、errorクラスの条件に対する条件ハンドラを指定することができます。このとき式exprの中でerrorオブジェクトのシグナルが送られると、条件ハンドラが呼び出されます。

tryCatch(
  expr = {
    print("式expr開始")
    
    # errorオブジェクト作成
    error_object <- simpleError(message = "エラーメッセージ")
    # シグナルを送る
    signalCondition(error_object)
    # 条件ハンドラにジャンプ
    
    print("この行は評価されない")
    print("式expr終了")
  },
  error = function(e) {
    print("条件ハンドリング開始")

    # 第1引数eにerrorオブジェクトが渡される
    print(e) # => <simpleError: エラーメッセージ>
    
    # errorオブジェクトからメッセージを取り出す
    error_message <- conditionMessage(e)
    print(error_message) # => [1] "エラーメッセージ"

    print("条件ハンドリング終了")
  },
  finally = {
    print("finally呼び出し")
  }
)
[1] "式expr開始"
[1] "条件ハンドリング開始"
<simpleError: エラーメッセージ>
[1] "エラーメッセージ"
[1] "条件ハンドリング終了"
[1] "finally呼び出し"

エラーを発生させたいとき、普通はsimpleError()signalCondition()ではなく stop()関数 (または stopifnot()関数 )を使います。

tryCatch(
  expr = {
    stop("エラー!") # 条件ハンドラへジャンプ

    print("ここは実行されない")
  },
  error = function(e) {
    print("条件ハンドラが呼び出されました")
    print(e)

    error_message <- conditionMessage(e)
    print(error_message)
  }
)
[1] "条件ハンドラが呼び出されました"
<simpleError in doTryCatch(return(expr), name, parentenv, handler): エラー!>
[1] "エラー!"

stop() 関数によってsimpleErrorオブジェクトのシグナルが送られていることがわかります。

普通に stop()関数を呼び出すとそこで処理が止まってしまいますが、条件ハンドラが設定されたtryCatchの中なら stop() でエラーが発生してもtryCatch()を抜けるだけで処理全体は中断せずに続きます。

withCallingHandlers

withCallingHandlers()も条件ハンドリングを行うための関数です。

次のようにしてerror条件に対する条件ハンドラを設定できます。 しかしtryCatch()とは異なり、条件ハンドラが呼び出された後に結局処理が停止してしまいます。

withCallingHandlers(
  expr = {
    print("式expr開始")

    stop("エラー!")

    print("式expr終了")
  },
  error = function(e) {
    print("条件ハンドラが呼び出されました")
  }
)
[1] "式expr開始"
[1] "条件ハンドラが呼び出されました"
Error in withCallingHandlers(expr = {: エラー!

複数の条件ハンドラが設定されているとき、 tryCatch()で設定された条件ハンドラが呼び出されると他の条件ハンドラはもう呼び出されません。 一方withCallingHandlers()の条件ハンドラが呼び出されたときは次に他の条件ハンドラも呼び出されます。そのため、stop()で発生したエラーに対してwithCallingHandlers()で条件ハンドラを設定していても、最後にデフォルトの条件ハンドラによって処理が中断されてしまいます。

withCallingHandlers()を入れ子にしてみると条件ハンドラが順次呼び出される様子がわかりやすいかもしれません。

handler1 <- function(e) { print("handler1") }
handler2 <- function(e) { print("handler2") }

withCallingHandlers(
  {
    withCallingHandlers(
      stop("エラー!"),
      error = handler1 # 最初の条件ハンドラ
    )
  },
  error = handler2 # 次の条件ハンドラ
)
[1] "handler1"
[1] "handler2"
Error in withCallingHandlers(stop("エラー!"), error = handler1): エラー!

warningとmessageの条件ハンドリング

tryCatchwithCallingHandlerで引数warningに関数を与えると、warningクラスの条件に対する条件ハンドラになります。

tryCatch(
  warning("警告!"),
  warning = function(w) {
    print("条件ハンドラが呼び出されました")
  }
)
[1] "条件ハンドラが呼び出されました"
withCallingHandlers(
  warning("警告!"),
  warning = function(w) {
    print("条件ハンドラが呼び出されました")
  }
)
[1] "条件ハンドラが呼び出されました"
Warning in withCallingHandlers(warning("警告!"), warning = function(w) {: 警
告!

messageに対する条件ハンドラも同様に指定できます。

tryCatch(
  message("メッセージ!"),
  message = function(w) {
    print("条件ハンドラが呼び出されました")
  }
)
[1] "条件ハンドラが呼び出されました"
withCallingHandlers(
  message("メッセージ!"),
  message = function(w) {
    print("条件ハンドラが呼び出されました")
  }
)
[1] "条件ハンドラが呼び出されました"
メッセージ!

制御フローの違い

tryCatch()の場合は、式exprの中でwarningが発生したら条件ハンドラにジャンプし、条件ハンドラが終わったらそのまま元の式を抜けてしまいます。

tryCatch(
  expr = {
    print("Hello!") # (1)
    warning("警告!") # (2) warningのシグナルを送る
    # (3) 下の条件ハンドラにジャンプする
    
    print("Bye!") # ここは実行されない
  },
  warning = function(w) {
    print("条件ハンドラが呼び出されました") # (3)
  }
) # (4) 条件ハンドラが終わったらtryCatchを抜ける
[1] "Hello!"
[1] "条件ハンドラが呼び出されました"

warningmessageが発生しただけで元の処理を抜けてしまうことは普通望まないので、このような使い方はしません。

withCallingHandlers()の場合は、呼び出された条件ハンドラが終わったら元の式に戻ります。

withCallingHandlers(
  expr = {
    print("Hello!") # (1)
    warning("警告!") # (2) warningのシグナルを送る
    # (3) 下の条件ハンドラにジャンプする。
    # (4) デフォルトの条件ハンドラによって警告メッセージが出力される。
    # すべての条件ハンドラが終わったらまたここから再開する

    print("Bye!") # (5) これが実行される
  },
  warning = function(w) {
    print("条件ハンドラ") # (3)
  }
)
[1] "Hello!"
[1] "条件ハンドラ"
Warning in withCallingHandlers(expr = {: 警告!
[1] "Bye!"

warningmessageの条件ハンドリングにはtryCatch()よりwithCallingHandlers()のほうが向いていると言えます。

コールスタックの違い

tryCatch()withCallingHandlers()の制御フローの違いはコールスタックから理解することもできます。 sys.calls() 関数を使うと、関数のコールスタックを調べることができます。

f <- function() { g() }
g <- function() { sys.calls() }

f()
[[1]]
f()

[[2]]
g()

このように関数f()から関数g()が呼び出されていることが示されます。

withCallingHandlers()の条件ハンドラでコールスタックを調べてみます。

f <- function() {
  g()
}
g <- function() { 
  e <- simpleError("Hello")
  signalCondition(e)
}

withCallingHandlers(
  {
    f()
  },
  error = function(e) {
    # コールスタックを調べる
    print(sys.calls())
  }
)
[[1]]
withCallingHandlers({
    f()
}, error = function(e) {
    print(sys.calls())
})

[[2]]
f()

[[3]]
g()

[[4]]
signalCondition(e)

[[5]]
(function(e) {
    # コールスタックを調べる
    print(sys.calls())
  })(list(message = "Hello", call = NULL))

ここでf()g()が含まれていることに注目してください。

同様に tryCatch()の条件ハンドラでコールスタックを調べると次のようになります。

f <- function() { g() }
g <- function() { 
  e <- simpleError("Hello")
  signalCondition(e)
  print("Bye!")
}

tryCatch(
  {
    f()
  },
  error = function(e) {
    # コールスタックを調べる
    print(sys.calls())
  }
)
[[1]]
tryCatch({
    f()
}, error = function(e) {
    print(sys.calls())
})

[[2]]
tryCatchList(expr, classes, parentenv, handlers)

[[3]]
tryCatchOne(expr, names, parentenv, handlers[[1L]])

[[4]]
value[[3L]](cond)

こちらのコールスタックにはf()g()が含まれていません。

エラー時のコールスタック表示

一般に、エラーが起きたきにはどこで問題が起きたのか調べるためコールスタックの情報を出力することがよくあります。

インタラクティブモードの場合は、エラーが発生した後で traceback() を実行することによってエラー時のコールスタックを調べることができます。

f <- function() { g() }
g <- function() { 
  stop("Error!")
}

f()
Error in g() : Error!
traceback()
3: stop("Error!") at #2
2: g() at #1
1: f()

traceback() はデフォルトではキャッチされなかった最後のエラーのコールスタック情報を表示します。 バッチモードではコールスタック情報をログに出力しておきたいのですが、tryCatch()の条件ハンドラでtraceback()を使ってもエラーはキャッチされているのでコールスタック情報は表示されません。

f <- function() { g() }
g <- function() {
  stop("Error!")
}

tryCatch(
  {
    f()
  },
  error = function(e) {
    traceback()
  }
)
No traceback available

traceback() に整数で引数を与えると、現在のコールスタック情報を表示することもできます。

f <- function() { g() }
g <- function() {
  traceback(1)
}
f()
3: traceback(1) at #2
2: g() at #1
1: f()

しかし tryCatch() の条件ハンドラで traceback(1) を実行しても、元のコールスタックを抜け出しているので有益な情報は得られません。

f <- function() { g() }
g <- function() { 
  stop("Error!")
}

tryCatch(
  {
    f()
  },
  error = function(e) {
    traceback(1)
  }
)
5: traceback(1) at #6
4: value[[3L]](cond)
3: tryCatchOne(expr, names, parentenv, handlers[[1L]])
2: tryCatchList(expr, classes, parentenv, handlers)
1: tryCatch({
       f()
   }, error = function(e) {
       traceback(1)
   })

エラー時のコールスタック情報を表示するにはwithCallingHandlers()のほうが適しています。

f <- function() { g() }
g <- function() {
  stop("Error!")
}

withCallingHandlers(
  {
    f()
  },
  error = function(e) {
    traceback(1)
  }
)
7: traceback(1) at #6
6: h(simpleError(msg, call))
5: .handleSimpleError(function (e) 
   {
       traceback(1)
   }, "Error!", base::quote(g()))
4: stop("Error!") at #2
3: g() at #1
2: f() at #3
1: withCallingHandlers({
       f()
   }, error = function(e) {
       traceback(1)
   })
Error in g() : Error!

ロギングについてはまた別の記事で詳しく扱いたいと思っています。

リスタート

リスタートは、条件発生時の対処方法を低水準の関数で実装しておき、対処方法の選択を高水準の関数で行う仕組みです。 これについては Hadley Wickham "Beyond exception handling" に詳しい解説があります。

リスタートを定義するにはwithRestarts()関数を、リスタートを選択して呼び出すにはinvokeRestart()関数を使います。

h <- function() {
  w <- simpleWarning("Warning!")
  signalCondition(w)
}
g <- function() {
  withRestarts(
    h(),
    # h()で発生した警告に対して、3種類のリスタートを定義
    ignore = function(w) NULL,
    show = function(w) { cat(conditionMessage(w)) },
    stop = function(w) { stop(conditionMessage(w)) }
  )
}
f <- function() {
  withCallingHandlers(
    g(),
    warning = function(w) {
      # リスタートを選択
      invokeRestart("show", w)
    }
  )
}

f()
Warning!

ここで関数g()withRestarts()を使い、条件の発生時に 1. 無視する (ignore)、2. 表示する (show)、3. 停止する (stop) の3通りのリスタートを定義しています。 関数f()は条件ハンドラでinvokeRestart()を使い、リスタートを選んで呼び出すことができます。

muffleMessage、muffleWarning

message()関数を実行すると、messageクラスの条件のシグナルが送られると同時にmuffleMessageというリスタートが定義されます。

定義されているリスタートを調べるにはcomputeRestarts()関数を使います。

withCallingHandlers(
  message("Hello"),
  message = function(m) {
    # 呼び出し可能なリスタートを表示
    print(computeRestarts(m))
  }
)
[[1]]
<restart: muffleMessage >

[[2]]
<restart: abort >

Hello

条件ハンドラでmuffleMessageリスタートを呼び出すと、メッセージの出力が抑制されます。

withCallingHandlers(
    message("Hello"),
    message = function(m) {
        # デフォルトのメッセージ出力を抑制
        tryInvokeRestart("muffleMessage")
    }
)
(何も表示されない)

ここではinvokeRestart()ではなくtryInvokeRestart()を使っています。こちらのほうがリスタートが存在しなかった場合には何もせず終了するので安全です。

warning()関数でも同様にmuffleWarningというリスタートが定義され、これを呼び出すとメッセージ出力が抑制されます。

withCallingHandlers(
  warning("Hello"),
  warning = function(w) {
    # デフォルトのメッセージ出力を抑制
    tryInvokeRestart("muffleWarning")
  }
)
(何も表示されない)

独自の条件クラス

simpleErrorsimpleWarningsimpleMessageの他にも、独自のクラスを定義して条件ハンドリングを行うこともできます。

例えば、conditionクラスとerrorクラスに加えて独自のクラスerror_not_foundを継承した条件オブジェクトを作成して、専用のエラーハンドラを設定することもできます。

tryCatch(
  {
    e <- errorCondition("log message", class = "error_not_found")
    stop(e)
  },
  error_not_found = function(e) {
    print("404 Not Found")
  },
  error = function(e) {
    print("500 Internal Error")
  }
)
[1] "404 Not Found"

ここでerrorCondition()はR 3.6.0で追加された関数で、conditionerrorを継承したオブジェクトを作成しています。

まとめ

  • Rのエラー、警告、メッセージはまとめて条件と呼ばれます。
  • 条件のシグナルが送られたとき、それに対処することを条件ハンドリングといいます。
  • 条件ハンドリングにはtryCatch()関数またはwithCallingHandlers()関数を利用します。
  • tryCatch()の条件ハンドリングは元の処理を抜け、withCallingHandlers()の条件ハンドリングは元の処理に戻ります。
  • tryCatch()の条件ハンドラが呼び出されると、他の条件ハンドラは呼び出されません。一方withCallingHandlers()の条件ハンドラが呼び出された後、他の条件ハンドラがあればそれも呼び出されます。
  • リスタートは条件ハンドリングの実装とその呼び出しとをコールスタックの階層を超えて分離する仕組みです。
  • 独自の条件クラスを定義することも可能です。

参考資料

  • R公式の説明はマニュアルの "Condition Handling and Recovery" に記されています。
  • 条件ハンドリングの入り口としては Hadley Wickham "Advanced R" 第2版の第8章 "Conditions" が最もわかりやすい説明です。
  • リスタートについては Hadley Wickham "Advanced R" 初版時に書かれた "Beyond exception handling" に詳しく解説されています。
  • Hadley Wickham "Advanced R" 初版の邦訳 『R言語徹底解説』 にも「条件ハンドリング」の章があります。ただしこの内容は原著第2版で大きく加筆・修正されています。