当你的R代码出现问题时,会发生什么?你应该做些什么?你有什么工具来处理这个问题?这篇文章将教你如何处理突如其来的问题(调试),为你展示怎样的函数能够传递问题,以及如何根据那些传递的信息采取行动(条件处理),并教导你在它们发生前如何避免常见的问题(防御性编程)。


调试是在你的代码中解决意外问题的艺术和科学。在这一节中,你将学习帮助你找到错误根源的工具和技术。你将学习用于调试的常规策略,有效的R函数像traceback() 和 browser(),以及RStudio中的交互式工具。


并非所有的问题都是意料之外的。在编写函数时,你通常可以预见潜在的问题(比如一个不存在的文件或输入错误的类型)。将这些问题传递给用户是条件的工作:错误、警告和消息。


  • 通过stop(),和强制执行去停止造成的重大错误。当函数无法继续时,就会发生错误。
  • 警告是由warning()生成的,并用于显示潜在的问题,比如某个向量输入的某些元素是无效的,比如log(-1:2)。
  • 消息是由message()生成的,并且用于提供有效的输出,这种方式很容易被用户禁止(?suppressMessages())。我通常使用消息让用户知道该函数选择了什么值来作为一个重要的缺失参数。


通常情况下,根据你的R界面,显示的是加粗的字体或被染成红色。你可以将它们区分开来,因为错误总是以“Error”,警告总是以“Warning message”开头。函数作者也可以用print()或cat()与用户进行通信,但我认为这是一个糟糕的主意,因为很难捕获并有选择性地忽略这类输出。打印输出并不是一个条件,因此你不能使用下面介绍的任何有用的条件处理工具。


条件处理工具,像withCallingHandlers(), tryCatch(), 和 try()允许你在条件发生时采取特定的操作。例如,如果你安装了许多模型,你可能希望继续对其他模型进行安装,即使其中一个不收敛。R提供了一个非常强大的条件处理系统,它基于Common Lisp的思想,但它目前还没有很好的文档记录,不被经常使用。本章将向你介绍最重要的基础知识,但如果你想了解更多,我推荐以下两个来源:


Robert Gentleman 和 Luke Tierney的A prototype of a condition system for R描述了一个R早期版本的条件系统。虽然自从该文档被编写以来,方法已经有所改变,但是它提供了一个很好的概述,说明各个部分是如何组合在一起的,以及它的设计的一些初衷。


Peter Seibel的Beyond Exception Handling: Conditions and Restarts描述了在Lisp中的异常处理,它与R的方法非常相似。它提供了积极的推动和更复杂的例子。我已经提供了这一章的R翻译,网址是http://adv-r.had.co.nz/beyond-exception-handling.html.


本章最后讨论了“防御性”编程:避免出现常见错误的方法。在短期内,你将花费更多的时间来编写代码,但是从长远来看,你将节省时间,因为错误消息将会更加丰富,并且会让你更快地缩小问题的根源。防御性编程的基本原则是“快速失败”,一旦出现问题,就会出现错误。在R中,这需要三种特殊的形式:检查输入是正确的,避免非标准的评估,避免返回不同类型输出的函数。


目录

  • 调试方法
  • 调试工具
  • 条件处理
  • 防御性编程
  • 测试的答案



测试

想跳过这一章吗?如果你能回答下面的问题,那就去做吧。在文章的末尾能找到答案。

  1. 如何查明错误发生在哪里?
  2. browser()是用来干什么的?列出你可以在browser()环境中使用的5个有用的单键命令。
  3. 你使用什么函数来忽略代码块中的错误?
  4. 为什么你想要使用自定义的S3类创建一个错误呢?


概要

  1. 调试方法概述了查找和解决bug的一般方法。
  2. 调试工具向你介绍了R函数和RStudio特性,这些特性可以帮助你准确定位错误发生的位置。
  3. 条件处理为你展示如何在你自己的代码中捕捉条件(错误、警告和消息)。允许你在出现错误的情况下,创造更强健、更好的代码。
  4. 防御性编程向你介绍了一些用于防御编程的重要技术,这些技术可以帮助防止错误在一开始就出现。


一、调试方法

“发现你的错误是一个确认你相信的许多事情的过程——直到你找到其中一个不是真的。”

——Norm Matloff


调试代码是具有挑战性的。许多错误是微妙的,很难找到。实际上,如果一个错误是显而易见的,那么你很可能在一开始就能够避免它。虽然使用好的技术是正确的,但是你可以用print()来有效地调试问题,有时还需要额外的帮助。在这一节中,我们将讨论一些R和RStudio提供的有用工具,并概括为一个通用的调试过程。


虽然下面的步骤绝不是万无一失的,但它可以帮助你在调试时组织好您的想法。有四个步骤:


1、意识到你有一个错误

如果你正在阅读这一章,你可能已经完成了这一步。这是一个非常重要的问题:直到你知道它的存在,你才能修复它。这就是为什么在生产高质量的代码时自动化测试套件很重要的原因之一。不幸的是,自动化测试超出了本书的范围,但是你可以阅读更多关于它的内容在http://r-pkgs.had.co.nz/tests.html.


2、使它可重复

一旦你确定了有一个错误,你就需要能够在命令中复制它。不这样做的话,去隔离产生错误的原因,并确认你已经成功地修复了它,将会是非常困难的。

通常,你将从一大块代码开始,你知道这些代码会导致错误,然后慢慢地将代码削减,以获得仍然导致错误的最小代码片段。二分查找对于这一点特别有用。进行二分查找,你反复删除一半的代码,直到找到错误。这是快速的,因为每一步都减少了一半的代码阅读量。

如果要花很长时间来生成这个错误,那么去找出如何更快地生成它也是值得的。你越快做到这一点,你就能越快地找出原因。

当你在创建一个最小的实例时,你还将发现类似这样的输入,这些输入不会产生错误。请注意:在诊断错误的原因时,它们将会有所帮助。如果你正在使用自动化测试,那么这也是创建自动化测试用例的好时机。如果你现有的测试覆盖率很低,请利用机会添加一些相近的测试,以确保现有的良好表现得到保留。这减少了创建新错误的机会。


3、找出它的位置

如果你是幸运的,下面一节中的工具将帮助你快速识别导致错误的代码行。然而,通常情况下,你需要多考虑一下这个问题。采用科学的方法是一个好主意。产生假设,设计实验来测试它们,并记录你的结果。这可能看起来工作量很大,但是系统的方法最终会节省你的时间。我经常会浪费很多时间来依靠我的直觉去解决问题(“哦,这肯定是一个误差,所以我在这里减去1”),如果我能更好地采取系统的方法。


4、解决并测试它

一旦你找到了错误,你就需要找出修复它的方法,并检查修复是否有效。同样,在适当的地方进行自动化测试是非常有用的。这不仅有助于确保你已经修复了错误,而且还有助于确保你在此过程中没有引入任何新的错误。在没有自动化测试的情况下,确保仔细记录正确的输出,并检查之前失败的输入。



二、调试工具

要实现调试策略,你需要工具。在本节中,你将了解R和RStudio 集成开发环境提供的工具。RStudio的集成调试支持通过以用户友好的方式公开现有的R工具使生活更加轻松。我将向你展示R和RStudio的方法,以便你可以在任何环境中使用。你可能还想参考官方文档 RStudio debugging documentation,它总是反映RStudio最新版本中的工具。


这里有三个关键的调试工具

  • RStudio的错误检查器和traceback()列出了导致错误的调用序列。
  • RStudio的“重新运行调试”工具和options(error = browser)打开了一个错误发生的交互会话。
  • RStudio的断点和browser()在代码的任意位置打开一个交互会话。

下面我将详细解释每个工具。


在编写新函数时,不需要使用这些工具。如果你发现自己经常使用新代码,那么你可能需要重新考虑你的方法。不要试图一次性写出一个大函数,而是在小片段上交互式地工作。如果你从小处着手,你就能很快地发现为什么有些东西不起作用。但是如果你从大处着手,你可能最终会苦斗于去发现问题的根源。


——确定调用序列

第一个工具是调用堆栈,导致错误的调用序列。这里有一个简单的例子:你可以看到f()调用g()调用h()调用i(),它将一个数字和一个字符添加到一个错误中:

f <- function(a) g(a)
g <- function(b) h(b)
h <- function(c) i(c)
i <- function(d) "a" + d
f(10)

当我们在RStudio中运行这段代码时,我们看到:

有两个选项出现在错误消息的右侧:“Show Traceback”和“Rerun with Debug”。如果你点击“Show traceback”,你会看到:

如果你没有使用RStudio,你可以使用traceback()来获得相同的信息:

traceback()
# 4: i(c) at exceptions-example.R#3
# 3: h(b) at exceptions-example.R#2
# 2: g(a) at exceptions-example.R#1
# 1: f(10)

从底部到顶部读取调用堆栈:初始调用是f(),它调用g(),然后是h(),然后是i(),它触发了错误。如果将你利用source()调用代码到R中,那么traceback也将显示该函数的位置,形如文件名.R #linenumber。这些在RStudio中是可点击的,并将你带到编辑器中相应的代码行。

有时,信息是足够充足的,可以让你跟踪错误并修复它。然而,这通常是不可能的。traceback()向你展示了错误发生的地方,但没有错误发生的原因。下一个有用的工具是交互式调试器,它允许你暂停一个函数的执行,并交互式地探索它的状态。


——错误时的浏览

进入交互式调试器的最简单方法是通过RStudio的“Rerun with Debug”工具。这将重新运行产生错误的命令,并在错误发生的地方暂停执行。你现在处于函数内部的交互状态,你可以与任何定义的对象进行交互。你将在编辑器中看到相应的代码(接下来将突出显示的语句)、“Environment”窗格中当前环境中的对象、“Traceback”窗格中的调用堆栈,你可以在控制台中运行任意的R代码。


除了常规的R函数,还有一些特殊的命令可以在调试模式中使用。你可以使用RStudio工具栏来访问它们

或用键盘:

  • Next, n:执行函数的下一个步骤。小心,如果你有一个名为n的变量;要输出它,你需要使用print(n)。
  • Step into,

or s: 像next一样工作,但是如果下一个步骤是一个函数,它就会进入那个函数,这样你就可以在每一行工作了。

  • Finish,

or f: 完成当前循环或函数的执行。

  • Continue, c:离开交互式调试,并继续函数的常规执行。如果你已经修复了坏状态,并且想要检查函数是否正确运行,那么这是非常有用的。
  • Stop, Q: 停止调试,终止该函数,并返回到全局工作区。一旦你找到问题所在,就可以使用它,并且你已经准备好修复它并重新加载代码。

这里还有另外两个在工具栏中找不到的,较少用到的命令:

  • Enter: 重复前面的命令。我发现它偶然间很容易就激活了,所以我把它关掉了:options(browserNLdisabled = TRUE)
  • where:输出有效调用的堆栈跟踪(交互等效于traceback)


要在RStudio之外输入这种调试风格你可以使用error选项,在发生错误时,指定一个函数去运行。与Rstudio的调试功能最相似的是browser():这将在发生错误的环境中启动一个交互式控制台。使用options(error = browser)去打开它,重新运行前面的命令,然后使用options(error = NULL)返回默认的错误情况。你可以使用如下所定义的browseOnce()函数来实现这一点:

browseOnce <- function() {
  old <- getOption("error")
  function() {
    options(error = old)
    browser()
  }
}
options(error = browseOnce())

f <- function() stop("!")
# Enters browser
f()
# Runs normally
f()

(你会了解到更多关于返回函数在Functional programming


你还可以使用另外两个有用的方法来使用error 选项:

  • recover是browser中的一步,因为它允许你进入调用堆栈中的任何一个调用的环境。
  • dump.frames相当于recover,是一种非交互式代码。它会在当前工作区域内创建一个last.dump.rda 文件。然后,在稍后的交互式R会话中,你将加载该文件,并使用debugger()进入一个与recover()界面相同的交互式调试器。允许对批处理代码进行交互式调试。
# In batch R process ----
dump_and_quit <- function() {
  # Save debugging info to file last.dump.rda
  dump.frames(to.file = TRUE)
  # Quit R with error status
  q(status = 1)
}
options(error = dump_and_quit)

# In a later interactive session ----
load("last.dump.rda")
debugger()

要将错误行为重置为缺省值,使用options(error=NULL)。然后,错误将输出一条消息并中止函数执行。


——浏览任意代码

在发生错误时,除了进入交互式控制台,你还能通过使用Rstudio断点或browser()来在任意代码位置进入它。你可以在Rstudio中通过单击左边的行号设置一个断点,或者按下Shift+F9。同样地,也可以在你希望暂停的位置添加browser()。断点的作用类似于browser(),但它更容易去设置(一次单击,而不是9个按键),而且你不会运行在源代码中粗心包含了的browser()语句。断点有两个小缺点:

  • 一些不常规的情况,断点不会起作用:更多详细信息请阅读breakpoint troubleshooting
  • RStudio目前不支持条件断点,而你可以在if语句中放入browser()。


除了添加browser()之外,还有两种方法可以将其添加到代码中:

  • debug() 在指定函数的第一行中插入一个浏览语句。undebug() 删掉它。或者,你可以使用debugonce()只在下一次运行时浏览。
  • utils::setBreakpoint() 作用类似,但不使用函数名,它需要一个文件名和行号,并为你找到合适的函数。


这两个函数都是trace()的特殊情况,它在现有函数中的任意位置插入任意代码。当你调试没有源代码的代码时, trace()偶尔会有用。从函数中删除跟踪,使用untrace()。你只能在每个函数中执行一个跟踪,但是这个跟踪可以调用多个函数。


——调用堆栈:traceback(), where, and recover()

不幸的是,traceback(), browser() + where,以及recover()的调用堆栈是不一致的。下表展示了由三个工具表示的简单嵌套调用集的调用堆栈是如何显示的。


请注意,traceback()和where的编号是不同的,并且recover()将以相反的顺序显示调用,还省略了stop()的调用。RStudio以与traceback()相同的顺序显示调用,但省略了这些数字。


——其他类型的故障

除了抛出错误或返回不正确的结果之外,还有其他方法可以使函数故障。

  • 一个函数也许生成一个出乎意料的警告。跟踪警告的最简单方法是使用options(warn = 2) 将它们转换为错误,并使用常规的调试工具。当你这样做时,你将会在调用堆栈中看到一些额外的调用,像doWithOneRestart(), withOneRestart(), withRestarts(), 和.signalSimpleWarning()。忽略这些:它们是用于将警告变为错误的内部函数。
  • 一个函数也许会生成一个意想不到的消息。没有内置的工具来帮助解决这个问题,但是有可能去创建一个:
message2error <- function(code) {
  withCallingHandlers(code, message = function(e) stop(e))
}

f <- function() g()
g <- function() message("Hi!")
g()
# Hi!   
message2error(g())
# Error in message("Hi!"): Hi!
traceback()
# 10: stop(e) at #2
# 9: (function (e) stop(e))(list(message = "Hi!\n", 
#      call = message("Hi!")))
# 8: signalCondition(cond)
# 7: doWithOneRestart(return(expr), restart)
# 6: withOneRestart(expr, restarts[[1L]])
# 5: withRestarts()
# 4: message("Hi!") at #1
# 3: g()
# 2: withCallingHandlers(code, message = function(e) stop(e)) 
#      at #2
# 1: message2error(g())

与警告一样,你需要忽略一些对traceback的调用(即:前两个和最后七个)。

  • 函数可能永远不会返回。这对于自动调试尤其困难,但是有时终止函数并查看调用堆栈是有效的。否则,使用上面描述的基本调试策略。
  • 最糟糕的情况是,你的代码可能完全崩溃,使你无法交互式地调试你的代码。这表示底层C代码中有一个错误。这很难调试。有时,像gdb这样的交互式调试器是有用的,但是描述如何使用它超出了本书的范围。

如果崩溃是由基本的R代码引起的,那么可以将一个可再生的例子发布到R-help上。如果问题是在包中,请联系包维护人员。如果是你自己的C或C++代码,你需要使用大量print()语句来缩小错误的位置,然后你需要使用更多的print语句来确定哪些数据结构没有你所期望的属性。



三、条件处理

意外的错误需要交互式调试来找出出错的地方。然而,有些错误是意料之中的,你想要自动处理它们。在R中,当你将许多模型安装到不同的数据集时,预期的错误就会频繁出现,比如自举复制。有时,模型可能无法匹配并抛出一个错误,但你并不想停止这一切。相反,你需要尽可能多地安装模型,然后在实际情况之后执行诊断。

在R中,有三种工具可以以编程方式处理条件(包括错误):

  • try() 让你即使出现错误,也能让你继续执行。
  • tryCatch() 让你指定处理函数,它可以控制在发出信号时发生的事情。
  • withCallingHandlers() 是tryCatch()的一种变体,它建立了本地处理程序,而tryCatch()则注册了退出的处理程序。本地处理程序在与条件被释放的相同的环境中被调用,而不会中断函数的执行。当调用tryCatch()的退出处理程序时,函数的执行被中断,并调用处理程序。withCallingHandlers()很少使用,但要知道它是有用的。

以下部分将更详细地描述这些工具。


——使用try()来忽略错误

try()允许继续执行,即使发生了错误。例如,通常如果运行一个带有错误的函数,它会立即终止,而不会返回值:

f1 <- function(x) {
  log(x)
  10
}
f1("x")
## Error in log(x): non-numeric argument to mathematical function

然后,如果你隐藏了在try()中发生错误的语句,错误消息将会输出,但将继续执行:

f2 <- function(x) {
  try(log(x))
  10
}
f2("a")
#> Error in log(x) : non-numeric argument to mathematical function
#> [1] 10

你能使用 try(..., silent = TRUE) 封锁这个消息。

将更大批的代码传达给try(),可将它们封装在 {} 中:

try({
  a <- 1
  b <- "x"
  a + b
})

你还可以获得try()函数的输出。如果成功,它将是在这一批中评估的最后一个结果(就像一个函数)。如果不成功,它将是 “try-error”类中的一个对象(不可见的):

success <- try(1 + 2)
failure <- try("a" + "b")
class(success)
## [1] "numeric"
class(failure)
## [1] "try-error"

当你将一个函数应用到列表中的多个元素时,try()特别有用:

elements <- list(1:10, c(-1, 10), c(TRUE, FALSE), letters)
results <- lapply(elements, log)
## Warning in FUN(X[[i]], ...): NaNs produced
## Error in FUN(X[[i]], ...): non-numeric argument to mathematical function
results <- lapply(elements, function(x) try(log(x)))
## Warning in log(x): NaNs produced

没有一个内置函数来测试try-error类,因此我们将定义一个。然后,你可以使用sapply()轻松的找到错误的位置(如函数中所讨论的),提取成功或查看导致失败的输入。

is.error <- function(x) inherits(x, "try-error")
succeeded <- !vapply(results, is.error, logical(1))

# look at successful results
str(results[succeeded])
## List of 3
##  $ : num [1:10] 0 0.693 1.099 1.386 1.609 ...
##  $ : num [1:2] NaN 2.3
##  $ : num [1:2] 0 -Inf
# look at inputs that failed
str(elements[!succeeded])
## List of 1
##  $ : chr [1:26] "a" "b" "c" "d" ...

另一个有用的try()习惯用法是在表达式失败时使用默认值。简单地将默认值分配到try块之外,然后运行风险代码:

default <- NULL
try(default <- read.csv("possibly-bad-input.csv"), silent = TRUE)

还有plyr::failwith(),使得该策略更容易实现。更多细节请参见Function Operators


——使用tryCatch()处理条件

tryCatch()是处理条件的通用工具:除了错误之外,你还可以针对警告、消息和中断采取不同的操作。你之前已经看到了错误(由stop()产生)、警告(warning())和消息(message()),但中断是新的。它们不能由程序员直接生成,但是当用户试图通过按Ctrl + Break, Escape或Ctrl+C(取决于平台)来终止执行时,能够被产生。在tryCatch()中,你将条件映射到处理程序,指定的函数作为输入被条件调用。如果一个条件被释放,tryCatch()将调用第一个处理程序,该程序的名称与条件的一个类匹配。唯一有用的内置名称是error, warning, message, interrupt,和 the catch-all condition。处理函数可以做任何事情,但是通常它要么返回一个值,要么创建一个更有用的错误消息。 例如,下面的show_condition()函数设置了返回条件类型的处理程序:

show_condition <- function(code) {
  tryCatch(code,
    error = function(c) "error",
    warning = function(c) "warning",
    message = function(c) "message"
  )
}
show_condition(stop("!"))
## [1] "error"
show_condition(warning("?!"))
## [1] "warning"
show_condition(message("?"))
## [1] "message"
# If no condition is captured, tryCatch returns the 
# value of the input
show_condition(10)
## [1] 10

你可以使用tryCatch()来实现try()。下面展示了一个简单的示例。如果不使用tryCatch(),为了使错误消息看起来更像你看到的那样,使用base::try()将是更复杂的。注意,使用条件conditionMessage()来提取与原始错误相关联的消息。

try2 <- function(code, silent = FALSE) {
  tryCatch(code, error = function(c) {
    msg <- conditionMessage(c)
    if (!silent) message(c)
    invisible(structure(msg, class = "try-error"))
  })
}

try2(1)
## [1] 1
try2(stop("Hi"))
try2(stop("Hi"), silent = TRUE)

除了在一个条件发出信号时返回默认值,还可以使用处理程序来生成更有意义的错误消息。例如,通过修改存储在错误条件对象中的消息,下面的函数封装了read.csv()来将文件名添加到任何错误中:

read.csv2 <- function(file, ...) {
  tryCatch(read.csv(file, ...), error = function(c) {
    c$message <- paste0(c$message, " (in ", file, ")")
    stop(c)
  })
}
read.csv("code/dummy.csv")
## Error in file(file, "rt"): cannot open the connection
read.csv2("code/dummy.csv")
## Error in file(file, "rt"): cannot open the connection (in code/dummy.csv)

如果你想要在用户尝试终止运行的代码时采取特殊的操作,那么捕获中断是很有用的。但是要小心,很容易创建一个你永远无法逃脱的循环(除非你干掉了R)!

# Don't let the user interrupt the code
i <- 1
while(i < 3) {
  tryCatch({
    Sys.sleep(0.5)
    message("Try to escape")
  }, interrupt = function(x) {
    message("Try again!")
    i <<- i + 1
  })
}

tryCatch()还有另一个争论: finally。它指定一批代码(而不是一个函数)来运行,而不管初始表达式是否成功或失败。这对清理工作很有帮助(例如:删除文件,关闭连接)。这在功能上等同于使用on.exit(),但是它可以封装比完整函数更小的代码块。


——withCallingHandlers()

tryCatch()的另一种选择是withcallinghandler()。两者之间的区别在于前者建立了退出的处理程序,而后者注册了本地处理程序。这两种处理程序之间的主要区别是:

  • withcallinghandler()的处理程序在生成条件的环境中被调用,而tryCatch()中的处理程序则在tryCatch()环境中调用。这里显示了sys.calls(),它运行时等价于traceback(),列出了产生当前函数的所有调用。
f <- function() g()
g <- function() h()
h <- function() stop("!")

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)

withCallingHandlers(f(), error = function(e) print(sys.calls()))
# [[1]] withCallingHandlers(f(), 
#    error = function(e) print(sys.calls()))
# [[2]] f()
# [[3]] g()
# [[4]] h()
# [[5]] stop("!")
# [[6]] .handleSimpleError(
#    function (e) print(sys.calls()), "!", quote(h()))
# [[7]] h(simpleError(msg, call))

这也会影响on.exit()被调用的顺序。

  • 一个相关的区别是,在tryCatch()中,当调用一个处理程序时,流的执行被中断,而使用withcallinghandler(),当处理程序返回时,执行会继续正常运行。这包括信号函数在调用处理程序后继续它的进程(如,stop()将继续停止程序和message(),而warning() 将继续发出消息/警告)。这就是为什么用withcallinghandler()而不是tryCatch()来处理消息通常更好,因为后者将停止该程序:
message_handler <- function(c) cat("Caught a message!\n")

tryCatch(message = message_handler, {
  message("Someone there?")
  message("Why, yes!")
})
## Caught a message!
withCallingHandlers(message = message_handler, {
  message("Someone there?")
  message("Why, yes!")
})
## Caught a message!
## Someone there?
## Caught a message!
## Why, yes!
  • 一个处理程序的返回值由tryCatch()返回,反之,使用withcallinghandler(),它将被忽略:
f <- function() message("!")
tryCatch(f(), message = function(m) 1)
## [1] 1
withCallingHandlers(f(), message = function(m) 1)
## !

这些细微的差别很少有用,除非你试图准确地捕捉到错误的地方并将其传递给另一个函数。在大多数情况下,你不需要使用withcallinghandler()。


——自定义信号类

在R中错误处理的一个挑战是,大多数函数都用一个字符串调用stop()。这意味着,如果你想要找出发生的某个特定错误,你必须查看错误消息的文本。这很容易出错,不仅因为错误的文本可能随着时间发生变化,还因为许多错误消息都被翻译了,所以消息可能与你所期望的完全不同。

R有一个鲜为人知的、很少使用的特性来解决这个问题。条件是S3类,因此你可以定义自己的类,如果你想要区分不同类型的错误。每个条件信号函数、stop(), warning(), 和message(),都可以给出一个字符串列表,或者一个自定义的S3条件对象。自定义条件对象不经常使用,但它们非常有用,因为它们使用户能够以不同的方式对不同的错误作出响应。例如,“预期的”错误(例如一个模型未能收敛于某些输入数据集)可以被忽略,而意外的错误(比如没有可用的磁盘空间)可以被传达给用户。

R并没有为条件提供内置的构造函数,但是我们可以很容易地添加一个。条件必须包含 message 和call组件,还可能包含其他有用的组件。在创建新条件时,它应该总是从条件中继承,并且在大多数情况下应该继承一个error, warning, 或 message.。

condition <- function(subclass, message, call = sys.call(-1), ...) {
  structure(
    class = c(subclass, "condition"),
    list(message = message, call = call),
    ...
  )
}
is.condition <- function(x) inherits(x, "condition")

你可以使用signalCondition()发出任意条件,但是除非你实例化了一个自定义信号处理程序,否则什么都不会发生(用tryCatch()或withCallingHandlers())。相反,将该条件传递给stop()、warning(), 或 message(),以触发常规的处理。如果你的条件与该函数不匹配,那么R不会控诉,但是在实际代码中,你应该传递一个继承自适当类的条件:"error" for stop(), "warning" for warning(), and "message" for message().

e <- condition(c("my_error", "error"), "This is an error")
signalCondition(e)
# NULL
stop(e)
# Error: This is an error
w <- condition(c("my_warning", "warning"), "This is a warning")
warning(w)
# Warning message: This is a warning
m <- condition(c("my_message", "message"), "This is a message")
message(m)
# This is a message

你可以使用tryCatch()对不同类型的错误采取不同的行动。在本例中,我们创建了一个方便的custom_stop()函数,该函数允许我们用任意类来表示错误条件。在实际的应用程序中,有一个单独的S3构造函数函数会是更好的,你可以更详细地记录、描述错误类别。

custom_stop <- function(subclass, message, call = sys.call(-1), 
                        ...) {
  c <- condition(c(subclass, "error"), message, call = call, ...)
  stop(c)
}

my_log <- function(x) {
  if (!is.numeric(x))
    custom_stop("invalid_class", "my_log() needs numeric input")
  if (any(x < 0))
    custom_stop("invalid_value", "my_log() needs positive inputs")

  log(x)
}
tryCatch(
  my_log("a"),
  invalid_class = function(c) "class",
  invalid_value = function(c) "value"
)
## [1] "class"

注意,当使用带有多个处理程序和自定义类的tryCatch()时,将调用第一个匹配信号类层次结构中的任何类的处理程序,而不是最好的匹配。出于这个原因,你需要确保将最特殊的处理程序放在第一位:

tryCatch(custom_stop("my_error", "!"),
  error = function(c) "error",
  my_error = function(c) "my_error"
)
## [1] "error"
tryCatch(custom_stop("my_error", "!"),
  my_error = function(c) "my_error",
  error = function(c) "error"
)
## [1] "my_error"


——练习

  • 比较下面两种message2error()的实现。在这种情况下,withcallinghandler()的主要优势是什么?(提示:仔细看后面的traceback。)
message2error <- function(code) {
  withCallingHandlers(code, message = function(e) stop(e))
}
message2error <- function(code) {
  tryCatch(code, message = function(e) stop(e))
}


四、防御性编程

防御性编程是使代码以一种良好定义的方式失败的艺术,即使发生了一些意想不到的事情。防御性编程的一个关键原则是“快速失败”:一旦发现了错误,就发出一个错误信号。这对于函数的作者(你!)来说是更多的工作,但是它使调试更容易,因为在意外的输入通过了几个函数之后,它们会更早地出现错误,而不是更晚。

在R中,“失败快速”原则以三种方式实现:

  • 对你所接受的东西要严格。例如,如果你的函数在其输入中没有被矢量化,但是使用的函数是,请检查确保输入是标量。你可以使用stopifnot()、 assertthat 包,或者简单if语句和stop()。
  • 避免使用非标准评估的功能,如subset, transform, 和 with。这些函数在使用交互时节省了时间,但是由于它们假设减少了输入,当它们失败时,它们通常会以没有用的错误消息失败。你可以更多地了解非标准评估在 non-standard evaluation
  • 避免返回不同类型的输出的函数,这取决于它们的输入。这两个最大的罪犯是 [ 和sapply()。无论何时在函数中设置一个数据子集,你都应该始终使用drop=FALSE,否则你将意外地将1列数据子集转换为矢量。类似地,永远不要在函数内部中使用sapply():总是使用更严格的vapply(),如果输入是不正确的类型,并且返回正确的输出类型,即使对于零长度的输入,也会抛出错误。

交互分析和编程之间存在着一种紧张关系。当你在交互式工作时,你想让R去做你想做的事情。如果它猜错了,你想马上发现它,这样你就可以修复它了。当你在编程的时候,你想要的是那些发出错误的函数,即使有细微的错误或者没有明确的说明。在写函数时,要保持这种紧张感。如果你正在编写函数来促进交互数据分析,可以自由地猜测分析人员想要什么,并自动地从较小的错误设定中恢复。如果你正在编写用于编程的函数,请严格执行。不要试图猜测对方想要什么。


——练习

  • 下面定义的col_means()函数的目标是计算数据框中所有数字列的方法。
col_means <- function(df) {
  numeric <- sapply(df, is.numeric)
  numeric_cols <- df[, numeric]

  data.frame(lapply(numeric_cols, mean))
}

然而,对于不常规的输入,该函数并不够强大。查看以下结果,确定哪些是不正确的,并修改col_means()以使其更加强大。(提示:在col_means()里有两个函数调用,特别容易出现问题。)

col_means(mtcars)
col_means(mtcars[, 0])
col_means(mtcars[0, ])
col_means(mtcars[, "mpg", drop = F])
col_means(1:10)
col_means(as.matrix(mtcars))
col_means(as.list(mtcars))

mtcars2 <- mtcars
mtcars2[-1] <- lapply(mtcars2[-1], as.character)
col_means(mtcars2)
  • 下面的函数“滞后”一个向量,返回一个x的版本,它的值是原来n后面的值。改进函数,如果n不是一个向量,使(1)返回一个有用的错误消息,(2)在n为0或比x更长的时候有合理的表现。
lag <- function(x, n = 1L) {
  xlen <- length(x)
  c(rep(NA, n), x[seq_len(xlen - n)])
}


五、测试的答案

  1. 确定错误发生位置的最有用的工具是traceback()。或者使用RStudio,它会自动显示错误发生的位置。
  2. browser() 在指定的行暂停执行,并允许你进入一个交互的环境。在这种环境下,有五个有用的命令:n,执行下一个命令;s,进入下一个函数;f,完成当前的循环或函数;c,继续正常执行;Q,停止该函数并返回到控制台。
  3. 你可以使用try()或tryCatch()。
  4. 因为你可以使用tryCatch()捕获特定类型的错误,而不是依赖于错误字符串的对照,这是有风险的,特别是当消息被翻译时。