8  Condiciones

8.1 Introducción

El sistema de condición proporciona un conjunto emparejado de herramientas que permiten al autor de una función indicar que algo inusual está sucediendo y al usuario de esa función manejarlo. El autor de la función señala las condiciones con funciones como stop() (para errores), warning() (para advertencias) y message() (para mensajes), luego el usuario de la función puede manejarlas. con funciones como tryCatch() y withCallingHandlers(). Comprender el sistema de condiciones es importante porque a menudo necesitará desempeñar ambos roles: señalar las condiciones de las funciones que crea y manejar las condiciones señaladas por las funciones que llama.

R ofrece un sistema de condiciones muy poderoso basado en ideas de Common Lisp. Al igual que el enfoque de R para la programación orientada a objetos, es bastante diferente a los lenguajes de programación actualmente populares, por lo que es fácil malinterpretarlo y se ha escrito relativamente poco sobre cómo usarlo de manera efectiva. Históricamente, esto ha significado que pocas personas (incluido yo mismo) han aprovechado al máximo su poder. El objetivo de este capítulo es remediar esa situación. Aquí aprenderá sobre las grandes ideas del sistema de condiciones de R, además de aprender un montón de herramientas prácticas que fortalecerán su código.

Encontré dos recursos particularmente útiles al escribir este capítulo. También puede leerlos si desea obtener más información sobre las inspiraciones y motivaciones del sistema:

También encontré útil trabajar con el código C subyacente que implementa estas ideas. Si está interesado en entender cómo funciona todo, puede encontrar mis notas de utilidad.

Prueba

¿Quieres saltarte este capítulo? Anímate, si puedes responder las siguientes preguntas. Encuentre las respuestas al final del capítulo en la Sección 8.7.

  1. ¿Cuáles son los tres tipos de condiciones más importantes?

  2. ¿Qué función utiliza para ignorar los errores en el bloque de código?

  3. ¿Cuál es la principal diferencia entre tryCatch() y withCallingHandlers()?

  4. ¿Por qué podría querer crear un objeto de error personalizado?

Estructura

  • La Sección 8.2 presenta las herramientas básicas para las condiciones de señalización y analiza cuándo es apropiado usar cada tipo.

  • La Sección 8.3 le enseña sobre las herramientas más simples para manejar condiciones: funciones como try() y suppressMessages() que tragan condiciones y evitan que lleguen al nivel superior.

  • La Sección 8.4 introduce la condición objeto y las dos herramientas fundamentales del manejo de condiciones: tryCatch() para condiciones de error y withCallingHandlers() para todo lo demás.

  • La Sección 8.5 le muestra cómo ampliar los objetos de condición incorporados para almacenar datos útiles que los controladores de condiciones pueden usar para tomar decisiones más informadas.

  • La Sección 8.6 cierra el capítulo con una bolsa de sorpresas de aplicaciones prácticas basadas en las herramientas de bajo nivel que se encuentran en las secciones anteriores.

8.1.1 Requisitos previos

Además de las funciones básicas de R, este capítulo utiliza funciones de señalización y manejo de condiciones de rlang.

library(rlang)

8.2 Condiciones de señalización

Hay tres condiciones que puede señalar en el código: errores, advertencias y mensajes.

  • Los errores son los más graves; indican que no hay forma de que una función continúe y la ejecución debe detenerse.

  • Las advertencias se encuentran un poco entre los errores y los mensajes, y generalmente indican que algo salió mal pero la función se pudo recuperar al menos parcialmente.

  • Los mensajes son los más suaves; son una forma de informar a los usuarios que se ha realizado alguna acción en su nombre.

Hay una condición final que solo se puede generar de forma interactiva: una interrupción, que indica que el usuario ha interrumpido la ejecución presionando Escape, Ctrl + Pausa o Ctrl + C (según la plataforma).

Las condiciones generalmente se muestran de manera destacada, en negrita o en color rojo, según la interfaz de R. Puede distinguirlos porque los errores siempre comienzan con “Error”, las advertencias con “Warning” o “Warning message” y los mensajes sin nada.

stop("Así es como se ve un error")
#> Error in eval(expr, envir, enclos): Así es como se ve un error

warning("Así es como se ve una advertencia")
#> Warning: Así es como se ve una advertencia

message("Así es como se ve un mensaje")
#> Así es como se ve un mensaje

Las siguientes tres secciones describen errores, advertencias y mensajes con más detalle.

8.2.1 Errores

En base R, los errores son señalados, o lanzados, por stop():

f <- function() g()
g <- function() h()
h <- function() stop("¡Esto es un error!")

f()
#> Error in h(): ¡Esto es un error!

De forma predeterminada, el mensaje de error incluye la llamada, pero esto normalmente no es útil (y recapitula información que puede obtener fácilmente de traceback()), por lo que creo que es una buena práctica usar call. = FALSE1:

h <- function() stop("¡Esto es un error!", call. = FALSE)
f()
#> Error: ¡Esto es un error!

El rlang equivalente a stop(), rlang::abort(), hace esto automáticamente. Usaremos abort() a lo largo de este capítulo, pero no llegaremos a su característica más convincente, la capacidad de agregar metadatos adicionales al objeto de condición, hasta que estemos cerca del final del capítulo.

h <- function() abort("This is an error!")
f()
#> Error in `h()`:
#> ! This is an error!

(NB: stop() pega varias entradas juntas, mientras que abort() no lo hace. Para crear mensajes de error complejos con abortar, recomiendo usar glue::glue(). Esto nos permite usar otros argumentos para abortar () para características útiles que aprenderá en la Sección 8.5.)

Los mejores mensajes de error le dicen qué está mal y le indican la dirección correcta para solucionar el problema. Escribir buenos mensajes de error es difícil porque los errores generalmente ocurren cuando el usuario tiene un modelo mental defectuoso de la función. Como desarrollador, es difícil imaginar cómo el usuario podría estar pensando incorrectamente sobre su función y, por lo tanto, es difícil escribir un mensaje que dirija al usuario en la dirección correcta. Dicho esto, la guía de estilo de tidyverse analiza algunos principios generales que hemos encontrado útiles: http://style.tidyverse.org/error-messages.html.

8.2.2 Advertencias

Las advertencias, señaladas por warning(), son más débiles que los errores: indican que algo salió mal, pero el código pudo recuperarse y continuar. A diferencia de los errores, puede tener múltiples advertencias de una sola llamada de función:

fw <- function() {
  cat("1\n")
  warning("W1")
  cat("2\n")
  warning("W2")
  cat("3\n")
  warning("W3")
}

De forma predeterminada, las advertencias se almacenan en caché y se imprimen solo cuando el control vuelve al nivel superior:

fw()
#> 1
#> 2
#> 3
#> Warning messages:
#> 1: In f() : W1
#> 2: In f() : W2
#> 3: In f() : W3

Puedes controlar este comportamiento con la opción warn:

  • Para que las advertencias aparezcan inmediatamente, configure options(warn = 1).

  • Para convertir las advertencias en errores, establezca options(warn = 2). Esta suele ser la forma más fácil de depurar una advertencia, ya que una vez que se trata de un error, puede usar herramientas como traceback() para encontrar la fuente.

  • Restaurar el comportamiento predeterminado con options(warn = 0).

Al igual que stop(), warning() también tiene un argumento de llamada. Es un poco más útil (ya que las advertencias a menudo están más lejos de su fuente), pero generalmente lo suprimo con call. = FALSE. Al igual que rlang::abort(), el equivalente en rlang de warning(), rlang::warn(), también suprime la call por defecto.

Las advertencias ocupan un lugar algo desafiante entre los mensajes (“debe saber sobre esto”) y los errores (“¡debe arreglar esto!”), y es difícil dar consejos precisos sobre cuándo usarlos. En general, tenga cuidado, ya que es fácil pasar por alto las advertencias si hay muchos otros resultados y no desea que su función se recupere con demasiada facilidad de una entrada claramente no válida. En mi opinión, la base R tiende a abusar de las advertencias, y muchas advertencias en la base R estarían mejor como errores. Por ejemplo, creo que estas advertencias serían más útiles como errores:

formals(1)
#> Warning in formals(fun): argument is not a function
#> NULL

file.remove("this-file-doesn't-exist")
#> Warning in file.remove("this-file-doesn't-exist"): cannot remove file
#> 'this-file-doesn't-exist', reason 'No such file or directory'
#> [1] FALSE

lag(1:3, k = 1.5)
#> Warning in lag.default(1:3, k = 1.5): 'k' is not an integer
#> [1] 1 2 3
#> attr(,"tsp")
#> [1] -1  1  1

as.numeric(c("18", "30", "50+", "345,678"))
#> Warning: NAs introduced by coercion
#> [1] 18 30 NA NA

Solo hay un par de casos en los que usar una advertencia es claramente apropiado:

  • Cuando desaproba una función, desea permitir que el código anterior continúe funcionando (por lo que ignorar la advertencia está bien), pero desea alentar al usuario a cambiar a una nueva función.

  • Cuando esté razonablemente seguro de que puede solucionar un problema: si estuviera 100% seguro de que podría solucionar el problema, no necesitaría ningún mensaje; si no estuviera seguro de poder solucionar correctamente el problema, arrojaría un error.

De lo contrario, use las advertencias con moderación y considere cuidadosamente si un error sería más apropiado.

8.2.3 Mensajes

Los mensajes, señalados por message(), son informativos; utilícelos para decirle al usuario que ha hecho algo en su nombre. Los buenos mensajes son un acto de equilibrio: desea proporcionar la información suficiente para que el usuario sepa lo que está sucediendo, pero no tanto como para que se sienta abrumado.

Los mensajes, message(), se muestran inmediatamente y no tienen un argumento call.:

fm <- function() {
  cat("1\n")
  message("M1")
  cat("2\n")
  message("M2")
  cat("3\n")
  message("M3")
}

fm()
#> 1
#> M1
#> 2
#> M2
#> 3
#> M3

Buenos lugares para usar un mensaje son:

  • Cuando un argumento predeterminado requiere una cantidad de cálculo no trivial y desea decirle al usuario qué valor se utilizó. Por ejemplo, ggplot2 informa la cantidad de contenedores utilizados si no proporciona un binwidth.

  • En funciones que son convocadas principalmente por sus efectos secundarios que de otro modo serían silenciosos. Por ejemplo, al escribir archivos en el disco, llamar a una API web o escribir en una base de datos, es útil proporcionar mensajes de estado regulares que le informen al usuario lo que está sucediendo.

  • Cuando esté a punto de iniciar un proceso de ejecución prolongada sin resultados intermedios. Una barra de progreso (por ejemplo, con progress) es mejor, pero un mensaje es un buen lugar para comenzar.

  • Al escribir un paquete, a veces desea mostrar un mensaje cuando se carga su paquete (es decir, en .onAttach()); aquí debes usar packageStartupMessage().

En general, cualquier función que produzca un mensaje debería tener alguna forma de suprimirlo, como un argumento quiet = TRUE. Es posible suprimir todos los mensajes con suppressMessages(), como aprenderá en breve, pero también es bueno dar un control más detallado.

Es importante comparar message() con el cat() estrechamente relacionado. En términos de uso y resultado, parecen bastante similares2:

cat("Hi!\n")
#> Hi!

message("Hi!")
#> Hi!

Sin embargo, los propósitos de cat() y message() son diferentes. Usa cat() cuando el rol principal de la función es imprimir en la consola, como los métodos print() o str(). Usa message() como un canal lateral para imprimir en la consola cuando el propósito principal de la función es otra cosa. En otras palabras, cat() es para cuando el usuario pide que se imprima algo y message() es para cuando el desarrollador elige imprimir algo.

8.2.4 Ejercicios

  1. Escriba un contenedor alrededor de file.remove() que arroje un error si el archivo a eliminar no existe.

  2. ¿Qué hace el argumento appendLF para message()? ¿Cómo se relaciona con cat()?

8.3 Ignorando las condiciones

La forma más sencilla de manejar las condiciones en R es simplemente ignorarlas:

  • Ignora los errores con try().
  • Ignora las advertencias con suppressWarnings().
  • Ignorar mensajes con suppressMessages().

Estas funciones son de mano dura, ya que no puede usarlas para suprimir un solo tipo de condición que conozca, mientras permite que pase todo lo demás. Volveremos a ese desafío más adelante en el capítulo.

try() permite que la ejecución continúe incluso después de que haya ocurrido un error. Normalmente, si ejecuta una función que arroja un error, finaliza inmediatamente y no devuelve un valor:

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

Sin embargo, si ajusta la declaración que crea el error en try(), se mostrará el mensaje de error 3 pero la ejecución continuará:

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

Es posible, pero no recomendado, guardar el resultado de try() y realizar diferentes acciones en función de si el código tuvo éxito o no 4. En su lugar, es mejor usar tryCatch() o un ayudante de nivel superior; aprenderá acerca de ellos en breve.

Un patrón simple, pero útil, es hacer una asignación dentro de la llamada: esto le permite definir un valor predeterminado que se usará si el código no funciona correctamente. Esto funciona porque el argumento se evalúa en el entorno de llamada, no dentro de la función. (Consulte la Sección 6.5.1 para obtener más detalles).

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

suppressWarnings() y suppressMessages() suprimir todas las advertencias y mensajes. A diferencia de los errores, los mensajes y advertencias no terminan la ejecución, por lo que puede haber múltiples advertencias y mensajes señalados en un solo bloque.

suppressWarnings({
  warning("Uhoh!")
  warning("Otra advertencia")
  1
})
#> [1] 1

suppressMessages({
  message("Hola")
  2
})
#> [1] 2

suppressWarnings({
  message("Todavía puedes verme")
  3
})
#> Todavía puedes verme
#> [1] 3

8.4 Controladores de condiciones

Cada condición tiene un comportamiento predeterminado: los errores detienen la ejecución y regresan al nivel superior, las advertencias se capturan y muestran en conjunto y los mensajes se muestran inmediatamente. Los controladores de condiciones nos permiten anular o complementar temporalmente el comportamiento predeterminado.

Dos funciones, tryCatch() y withCallingHandlers(), nos permiten registrar controladores, funciones que toman la condición señalada como único argumento. Las funciones de registro tienen la misma forma básica:

tryCatch(
  error = function(cnd) {
    # código para ejecutar cuando se lanza un error
  },
  code_to_run_while_handlers_are_active
)

withCallingHandlers(
  warning = function(cnd) {
    # código para ejecutar cuando se señale una advertencia
  },
  message = function(cnd) {
    # código para ejecutar cuando se señala el mensaje
  },
  code_to_run_while_handlers_are_active
)

Se diferencian en el tipo de controladores que crean:

  • tryCatch() define controladores que salen; después de manejar la condición, el control regresa al contexto donde se llamó a tryCatch(). Esto hace que tryCatch() sea más adecuado para trabajar con errores e interrupciones, ya que estos tienen que salir de todos modos.

  • withCallingHandlers() define controladores de llamadas; después de capturar la condición, el control vuelve al contexto donde se señaló la condición. Esto lo hace más adecuado para trabajar con condiciones sin error.

Pero antes de que podamos aprender y usar estos controladores, necesitamos hablar un poco sobre la condición objetos. Estos se crean implícitamente cada vez que señala una condición, pero se vuelven explícitos dentro del controlador.

8.4.1 Objetos de condición

Hasta ahora, solo hemos señalado las condiciones y no hemos mirado los objetos que se crean detrás de escena. La forma más fácil de ver un objeto de condición es atrapar uno de una condición señalada. ese es el trabajo de rlang::catch_cnd():

cnd <- catch_cnd(stop("An error"))
str(cnd)
#> List of 2
#>  $ message: chr "An error"
#>  $ call   : language force(expr)
#>  - attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

Las condiciones integradas son listas con dos elementos:

  • menssage, un vector de caracteres de longitud 1 que contiene el texto para mostrar a un usuario. Para extraer el mensaje, utilice conditionMessage(cnd).

  • call, la llamada que activó la condición. Como se describió anteriormente, no usamos la llamada, por lo que a menudo será NULL. Para extraerlo, usa conditionCall(cnd).

Las condiciones personalizadas pueden contener otros componentes, que analizaremos en la Sección 8.5.

Las condiciones también tienen un atributo class, lo que las convierte en objetos S3. No hablaremos de S3 hasta el Capítulo 13, pero afortunadamente, incluso si no conoce S3, los objetos de condición son bastante simples. Lo más importante que debe saber es que el atributo class es un vector de caracteres y determina qué controladores coincidirán con la condición.

8.4.2 Controladores de salida

tryCatch() egistra los controladores existentes y, por lo general, se utiliza para controlar las condiciones de error. Le permite anular el comportamiento de error predeterminado. Por ejemplo, el siguiente código devolverá NA en lugar de arrojar un error:

f3 <- function(x) {
  tryCatch(
    error = function(cnd) NA,
    log(x)
  )
}

f3("x")
#> [1] NA

Si no se señalan condiciones, o si la clase de la condición señalada no coincide con el nombre del controlador, el código se ejecuta normalmente:

tryCatch(
  error = function(cnd) 10,
  1 + 1
)
#> [1] 2

tryCatch(
  error = function(cnd) 10,
  {
    message("Hi!")
    1 + 1
  }
)
#> Hi!
#> [1] 2

Los controladores configurados por tryCatch() se denominan controladores exiting porque después de señalar la condición, el control pasa al controlador y nunca vuelve al código original, lo que significa que el código sale:

tryCatch(
  message = function(cnd) "There",
  {
    message("Here")
    stop("This code is never run!")
  }
)
#> [1] "There"

El código protegido se evalúa en el entorno de tryCatch(), pero el código del controlador no, porque los controladores son funciones. Es importante recordar esto si está tratando de modificar objetos en el entorno principal.

Las funciones del controlador se llaman con un solo argumento, el objeto de condición. Llamo a este argumento cnd, por convención. Este valor es solo moderadamente útil para las condiciones base porque contienen relativamente pocos datos. Es más útil cuando crea sus propias condiciones personalizadas, como verá en breve.

tryCatch(
  error = function(cnd) {
    paste0("--", conditionMessage(cnd), "--")
  },
  stop("This is an error")
)
#> [1] "--This is an error--"

tryCatch() tiene otro argumento: finally. Especifica un bloque de código (no una función) para ejecutar independientemente de si la expresión inicial tiene éxito o falla. Esto puede ser útil para la limpieza, como eliminar archivos o cerrar conexiones. Esto es funcionalmente equivalente a usar on.exit() (y de hecho así es como se implementa), pero puede envolver fragmentos de código más pequeños que una función completa.

path <- tempfile()
tryCatch(
  {
    writeLines("Hi!", path)
    # ...
  },
  finally = {
    # always run
    unlink(path)
  }
)

8.4.3 Controladores de llamadas

Los controladores configurados por tryCatch() se denominan controladores de salida porque hacen que el código se cierre una vez que se ha detectado la condición. Por el contrario, withCallingHandlers() configura controladores de llamadas: la ejecución del código continúa normalmente una vez que el controlador regresa. Esto tiende a hacer que withCallingHandlers() sea un emparejamiento más natural con las condiciones sin error. Los controladores de salida y llamada usan “controlador” en sentidos ligeramente diferentes:

  • Un controlador existente maneja una señal como tú manejas un problema; hace que el problema desaparezca.

  • Un controlador de llamadas maneja una señal como usted maneja un automóvil; el coche todavía existe.

Compara los resultados de tryCatch() y withCallingHandlers() en el siguiente ejemplo. Los mensajes no se imprimen en el primer caso, porque el código finaliza una vez que se completa el controlador de salida. Se imprimen en el segundo caso, porque un controlador de llamadas no sale.

tryCatch(
  message = function(cnd) cat("Caught a message!\n"), 
  {
    message("Someone there?")
    message("Why, yes!")
  }
)
#> Caught a message!

withCallingHandlers(
  message = function(cnd) cat("Caught a message!\n"), 
  {
    message("Someone there?")
    message("Why, yes!")
  }
)
#> Caught a message!
#> Someone there?
#> Caught a message!
#> Why, yes!

Los controladores se aplican en orden, por lo que no debe preocuparse por quedar atrapado en un bucle infinito. En el siguiente ejemplo, el message() señalado por el controlador tampoco queda atrapado:

withCallingHandlers(
  message = function(cnd) message("Second message"),
  message("First message")
)
#> Second message
#> First message

(Pero tenga cuidado si tiene varios controladores y algunos controladores señalan condiciones que podrían ser capturadas por otro controlador: tendrá que pensar detenidamente en la orden).

El valor de retorno de un controlador de llamadas se ignora porque el código continúa ejecutándose después de que se completa el controlador; ¿Adónde iría el valor de retorno? Eso significa que los controladores de llamadas solo son útiles por sus efectos secundarios.

Un efecto secundario importante exclusivo de los controladores de llamadas es la capacidad de amortiguar la señal. De forma predeterminada, una condición continuará propagándose a los controladores principales, hasta el controlador predeterminado (o un controlador existente, si se proporciona):

# Burbujas hasta el controlador predeterminado que genera el mensaje
withCallingHandlers(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    message("Hello")
  )
)
#> Level 1
#> Level 2
#> Hello

# Burbujas en tryCatch
tryCatch(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    message("Hello")
  )
)
#> Level 1
#> Level 2

Si desea evitar la condición “burbujeante” pero aún ejecuta el resto del código en el bloque, debe silenciarlo explícitamente con rlang::cnd_muffle():

# Silencia el controlador predeterminado que imprime los mensajes.
withCallingHandlers(
  message = function(cnd) {
    cat("Level 2\n")
    cnd_muffle(cnd)
  },
  withCallingHandlers(
    message = function(cnd) cat("Level 1\n"),
    message("Hello")
  )
)
#> Level 1
#> Level 2

# Silencia el controlador de nivel 2 y el controlador por defecto
withCallingHandlers(
  message = function(cnd) cat("Level 2\n"),
  withCallingHandlers(
    message = function(cnd) {
      cat("Level 1\n")
      cnd_muffle(cnd)
    },
    message("Hello")
  )
)
#> Level 1

8.4.4 Pilas de llamadas

Para completar la sección, existen algunas diferencias importantes entre las pilas de llamadas de los controladores de salida y de llamada. Estas diferencias generalmente no son importantes, pero las incluyo aquí porque ocasionalmente las he encontrado útiles, ¡y no quiero olvidarme de ellas!

Es más fácil ver la diferencia configurando un pequeño ejemplo que usa lobstr::cst():

f <- function() g()
g <- function() h()
h <- function() message("!")

Los controladores de llamadas se llaman en el contexto de la llamada que señaló la condición:

withCallingHandlers(f(), message = function(cnd) {
  lobstr::cst()
  cnd_muffle(cnd)
})
#>      ▆
#>   1. ├─base::withCallingHandlers(...)
#>   2. ├─global f()
#>   3. │ └─global g()
#>   4. │   └─global h()
#>   5. │     └─base::message("!")
#>   6. │       ├─base::withRestarts(...)
#>   7. │       │ └─base (local) withOneRestart(expr, restarts[[1L]])
#>   8. │       │   └─base (local) doWithOneRestart(return(expr), restart)
#>   9. │       └─base::signalCondition(cond)
#>  10. └─global `<fn>`(`<smplMssg>`)
#>  11.   └─lobstr::cst()

Mientras que los controladores existentes se llaman en el contexto de la llamada a tryCatch():

tryCatch(f(), message = function(cnd) lobstr::cst())
#>     ▆
#>  1. └─base::tryCatch(f(), message = function(cnd) lobstr::cst())
#>  2.   └─base (local) tryCatchList(expr, classes, parentenv, handlers)
#>  3.     └─base (local) tryCatchOne(expr, names, parentenv, handlers[[1L]])
#>  4.       └─value[[3L]](cond)
#>  5.         └─lobstr::cst()

8.4.5 Ejercicios

  1. ¿Qué información adicional contiene la condición generada por abort() en comparación con la condición generada por stop(), es decir, cuál es la diferencia entre estos dos objetos? Lea la ayuda de ?abort para obtener más información.

    catch_cnd(stop("An error"))
    catch_cnd(abort("An error"))
  2. Prediga los resultados de evaluar el siguiente código

    show_condition <- function(code) {
      tryCatch(
        error = function(cnd) "error",
        warning = function(cnd) "warning",
        message = function(cnd) "message",
        {
          code
          NULL
        }
      )
    }
    
    show_condition(stop("!"))
    show_condition(10)
    show_condition(warning("?!"))
    show_condition({
      10
      message("?")
      warning("?!")
    })
  3. Explique los resultados de ejecutar este código:

    withCallingHandlers(
      message = function(cnd) message("b"),
      withCallingHandlers(
        message = function(cnd) message("a"),
        message("c")
      )
    )
    #> b
    #> a
    #> b
    #> c
  4. Lea el código fuente de catch_cnd() y explique cómo funciona.

  5. ¿Cómo podría reescribir show_condition() para usar un solo controlador?

8.5 Condiciones personalizadas

Uno de los desafíos del manejo de errores en R es que la mayoría de las funciones generan una de las condiciones integradas, que contienen solo un “mensaje” y una “llamada”. Eso significa que si desea detectar un tipo específico de error, solo puede trabajar con el texto del mensaje de error. Esto es propenso a errores, no solo porque el mensaje puede cambiar con el tiempo, sino también porque los mensajes se pueden traducir a otros idiomas.

Afortunadamente, R tiene una característica poderosa, pero poco utilizada: la capacidad de crear condiciones personalizadas que pueden contener metadatos adicionales. Crear condiciones personalizadas es un poco complicado en base R, pero rlang::abort() lo hace muy fácil ya que puede proporcionar una .subclass personalizada y metadatos adicionales.

El siguiente ejemplo muestra el patrón básico. Recomiendo usar la siguiente estructura de llamadas para condiciones personalizadas. Esto aprovecha la coincidencia de argumentos flexibles de R para que el nombre del tipo de error aparezca primero, seguido del texto de cara al usuario, seguido de los metadatos personalizados.

abort(
  "error_not_found",
  message = "Path `blah.csv` not found", 
  path = "blah.csv"
)
#> Error:
#> ! Path `blah.csv` not found

Las condiciones personalizadas funcionan igual que las condiciones normales cuando se usan de forma interactiva, pero permiten que los controladores hagan mucho más.

8.5.1 Motivación

To explore these ideas in more depth, let’s take base::log(). It does the minimum when throwing errors caused by invalid arguments:

log(letters)
#> Error in log(letters): non-numeric argument to mathematical function
log(1:10, base = letters)
#> Error in log(1:10, base = letters): non-numeric argument to mathematical function

Creo que podemos hacerlo mejor siendo explícitos sobre qué argumento es el problema (es decir, x o base) y diciendo cuál es la entrada problemática (no solo cuál no es).

my_log <- function(x, base = exp(1)) {
  if (!is.numeric(x)) {
    abort(paste0(
      "`x` must be a numeric vector; not ", typeof(x), "."
    ))
  }
  if (!is.numeric(base)) {
    abort(paste0(
      "`base` must be a numeric vector; not ", typeof(base), "."
    ))
  }

  base::log(x, base = base)
}

Esto nos da:

my_log(letters)
#> Error in `my_log()`:
#> ! `x` must be a numeric vector; not character.
my_log(1:10, base = letters)
#> Error in `my_log()`:
#> ! `base` must be a numeric vector; not character.

Esta es una mejora para el uso interactivo, ya que es más probable que los mensajes de error guíen al usuario hacia una solución correcta. Sin embargo, no son mejores si desea manejar los errores mediante programación: todos los metadatos útiles sobre el error se atascan en una sola cadena.

8.5.2 Señalización

Construyamos alguna infraestructura para mejorar esta situación. Comenzaremos proporcionando una función abort() personalizada para argumentos incorrectos. Esto está un poco generalizado para el ejemplo en cuestión, pero refleja patrones comunes que he visto en otras funciones. El patrón es bastante simple. Creamos un buen mensaje de error para el usuario, usando glue::glue(), y almacenamos metadatos en la llamada de condición para el desarrollador.

abort_bad_argument <- function(arg, must, not = NULL) {
  msg <- glue::glue("`{arg}` must {must}")
  if (!is.null(not)) {
    not <- typeof(not)
    msg <- glue::glue("{msg}; not {not}.")
  }
  
  abort("error_bad_argument", 
    message = msg, 
    arg = arg, 
    must = must, 
    not = not
  )
}

Si desea generar un error personalizado sin agregar una dependencia en rlang, puede crear un objeto de condición “a mano” y luego pasarlo a stop():

stop_custom <- function(.subclass, message, call = NULL, ...) {
  err <- structure(
    list(
      message = message,
      call = call,
      ...
    ),
    class = c(.subclass, "error", "condition")
  )
  stop(err)
}

err <- catch_cnd(
  stop_custom("error_new", "This is a custom error", x = 10)
)
class(err)
err$x

Ahora podemos reescribir my_log() para usar este nuevo ayudante:

my_log <- function(x, base = exp(1)) {
  if (!is.numeric(x)) {
    abort_bad_argument("x", must = "be numeric", not = x)
  }
  if (!is.numeric(base)) {
    abort_bad_argument("base", must = "be numeric", not = base)
  }

  base::log(x, base = base)
}

my_log() en sí mismo no es mucho más corto, pero es un poco más significativo y asegura que los mensajes de error para argumentos incorrectos sean consistentes en todas las funciones. Produce los mismos mensajes de error interactivos que antes:

my_log(letters)
#> Error in `abort_bad_argument()`:
#> ! `x` must be numeric; not character.
my_log(1:10, base = letters)
#> Error in `abort_bad_argument()`:
#> ! `base` must be numeric; not character.

8.5.3 Controlar

Estos objetos de condición estructurados son mucho más fáciles de programar. El primer lugar en el que podría querer usar esta capacidad es al probar su función. Las pruebas unitarias no son un tema de este libro (consulte los paquetes R para obtener más detalles), pero los conceptos básicos son fáciles de entender. El siguiente código captura el error y luego afirma que tiene la estructura que esperamos.

library(testthat)

err <- catch_cnd(my_log("a"))
expect_s3_class(err, "error_bad_argument")
expect_equal(err$arg, "x")
expect_equal(err$not, "character")

También podemos usar la clase (error_bad_argument) en tryCatch() para manejar solo ese error específico:

tryCatch(
  error_bad_argument = function(cnd) "bad_argument",
  error = function(cnd) "other error",
  my_log("a")
)
#> [1] "bad_argument"

Cuando se usa tryCatch() con múltiples controladores y clases personalizadas, se llama al primer controlador que coincida con cualquier clase en el vector de clase de la señal, no a la mejor coincidencia. Por este motivo, debe asegurarse de poner primero los controladores más específicos. El siguiente código no hace lo que cabría esperar:

tryCatch(
  error = function(cnd) "other error",
  error_bad_argument = function(cnd) "bad_argument",
  my_log("a")
)
#> [1] "other error"

8.5.4 Ejercicios

  1. Dentro de un paquete, ocasionalmente es útil verificar que un paquete esté instalado antes de usarlo. Escriba una función que verifique si un paquete está instalado (con requireNamespace("pkg", quietly = FALSE)) y, si no, arroja una condición personalizada que incluye el nombre del paquete en los metadatos.

  2. Dentro de un paquete, a menudo debe detenerse con un error cuando algo no está bien. Otros paquetes que dependen de su paquete pueden verse tentados a verificar estos errores en sus pruebas unitarias. ¿Cómo podría ayudar a estos paquetes a evitar confiar en el mensaje de error que es parte de la interfaz de usuario en lugar de la API y que podría cambiar sin previo aviso?

8.6 Aplicaciones

Ahora que ha aprendido las herramientas básicas del sistema de condiciones de R, es hora de sumergirse en algunas aplicaciones. El objetivo de esta sección no es mostrar todos los usos posibles de tryCatch() y withCallingHandlers(), sino ilustrar algunos patrones comunes que surgen con frecuencia. Con suerte, esto hará que fluya su creatividad, de modo que cuando encuentre un nuevo problema, pueda encontrar una solución útil.

8.6.1 Valor de falla

Hay algunos patrones tryCatch() simples, pero útiles, basados en la devolución de un valor del controlador de errores. El caso más simple es un contenedor para devolver un valor predeterminado si ocurre un error:

fail_with <- function(expr, value = NULL) {
  tryCatch(
    error = function(cnd) value,
    expr
  )
}

fail_with(log(10), NA_real_)
#> [1] 2.3
fail_with(log("x"), NA_real_)
#> [1] NA

Una aplicación más sofisticada es base::try(). A continuación, try2() extrae la esencia de base::try(); la función real es más complicada para hacer que el mensaje de error se parezca más a lo que vería si no se usara tryCatch().

try2 <- function(expr, silent = FALSE) {
  tryCatch(
    error = function(cnd) {
      msg <- conditionMessage(cnd)
      if (!silent) {
        message("Error: ", msg)
      }
      structure(msg, class = "try-error")
    },
    expr
  )
}

try2(1)
#> [1] 1
try2(stop("Hi"))
#> Error: Hi
#> [1] "Hi"
#> attr(,"class")
#> [1] "try-error"
try2(stop("Hi"), silent = TRUE)
#> [1] "Hi"
#> attr(,"class")
#> [1] "try-error"

8.6.2 Valores de éxito y fracaso.

Podemos ampliar este patrón para devolver un valor si el código se evalúa correctamente (success_val) y otro si falla (error_val). Este patrón solo requiere un pequeño truco: evaluar el código proporcionado por el usuario y luego success_val. Si el código arroja un error, nunca llegaremos a success_val y, en su lugar, devolveremos error_val.

foo <- function(expr) {
  tryCatch(
    error = function(cnd) error_val,
    {
      expr
      success_val
    }
  )
}

Podemos usar esto para determinar si una expresión falla:

does_error <- function(expr) {
  tryCatch(
    error = function(cnd) TRUE,
    {
      expr
      FALSE
    }
  )
}

O para capturar cualquier condición, como simplemente rlang::catch_cnd():

catch_cnd <- function(expr) {
  tryCatch(
    condition = function(cnd) cnd, 
    {
      expr
      NULL
    }
  )
}

También podemos usar este patrón para crear una variante try(). Un desafío con try() es que es un poco difícil determinar si el código tuvo éxito o falló. En lugar de devolver un objeto con una clase especial, creo que es un poco mejor devolver una lista con dos componentes result y error.

safety <- function(expr) {
  tryCatch(
    error = function(cnd) {
      list(result = NULL, error = cnd)
    },
    list(result = expr, error = NULL)
  )
}

str(safety(1 + 10))
#> List of 2
#>  $ result: num 11
#>  $ error : NULL
str(safety(stop("Error!")))
#> List of 2
#>  $ result: NULL
#>  $ error :List of 2
#>   ..$ message: chr "Error!"
#>   ..$ call   : language doTryCatch(return(expr), name, parentenv, handler)
#>   ..- attr(*, "class")= chr [1:3] "simpleError" "error" "condition"

(Esto está estrechamente relacionado con purrr::safely(), un operador de función, al que volveremos en la Sección 11.2.1.)

8.6.3 Renuncia

Además de devolver valores predeterminados cuando se señala una condición, los controladores se pueden usar para generar mensajes de error más informativos. Una aplicación simple es hacer una función que funcione como options(warn = 2) para un solo bloque de código. La idea es simple: manejamos las advertencias lanzando un error:

warning2error <- function(expr) {
  withCallingHandlers(
    warning = function(cnd) abort(conditionMessage(cnd)),
    expr
  )
}
warning2error({
  x <- 2 ^ 4
  warn("Hello")
})
#> Error:
#> ! Hello

Podrías escribir una función similar si estuvieras tratando de encontrar la fuente de un mensaje molesto. Más sobre esto en la Sección 22.6.

8.6.4 Registro

Otro patrón común es registrar las condiciones para una investigación posterior. El nuevo desafío aquí es que los controladores de llamadas se llaman solo por sus efectos secundarios, por lo que no podemos devolver valores, sino que necesitamos modificar algún objeto en su lugar.

catch_cnds <- function(expr) {
  conds <- list()
  add_cond <- function(cnd) {
    conds <<- append(conds, list(cnd))
    cnd_muffle(cnd)
  }
  
  withCallingHandlers(
    message = add_cond,
    warning = add_cond,
    expr
  )
  
  conds
}

catch_cnds({
  inform("a")
  warn("b")
  inform("c")
})
#> [[1]]
#> <message/rlang_message>
#> Message:
#> a
#> 
#> [[2]]
#> <warning/rlang_warning>
#> Warning:
#> b
#> 
#> [[3]]
#> <message/rlang_message>
#> Message:
#> c

¿Qué sucede si también desea capturar errores? Deberá envolver el withCallingHandlers() en un tryCatch(). Si se produce un error, será la última condición.

catch_cnds <- function(expr) {
  conds <- list()
  add_cond <- function(cnd) {
    conds <<- append(conds, list(cnd))
    cnd_muffle(cnd)
  }
  
  tryCatch(
    error = function(cnd) {
      conds <<- append(conds, list(cnd))
    },
    withCallingHandlers(
      message = add_cond,
      warning = add_cond,
      expr
    )
  )
  
  conds
}

catch_cnds({
  inform("a")
  warn("b")
  abort("C")
})
#> [[1]]
#> <message/rlang_message>
#> Message:
#> a
#> 
#> [[2]]
#> <warning/rlang_warning>
#> Warning:
#> b
#> 
#> [[3]]
#> <error/rlang_error>
#> Error:
#> ! C
#> ---
#> Backtrace:
#> ▆

Esta es la idea clave que subyace al paquete de evaluación (Wickham y Xie 2018) que impulsa a knitr: captura cada salida en una estructura de datos especial para que pueda reproducirse más tarde. En general, el paquete de evaluación es mucho más complicado que el código aquí porque también necesita manejar gráficos y salida de texto.

8.6.5 Sin comportamiento predeterminado

Un último patrón útil es señalar una condición que no hereda de message, warning o error. Debido a que no hay un comportamiento predeterminado, esto significa que la condición no tiene efecto a menos que el usuario lo solicite específicamente. Por ejemplo, podría imaginar un sistema de registro basado en condiciones:

log <- function(message, level = c("info", "error", "fatal")) {
  level <- match.arg(level)
  signal(message, "log", level = level)
}

Cuando llamas a log(), se señala una condición, pero no sucede nada porque no tiene un controlador predeterminado:

log("This code was run")

Para activar el registro, necesita un controlador que haga algo con la condición log. A continuación defino una función record_log() que registrará todos los mensajes de registro en un archivo:

record_log <- function(expr, path = stdout()) {
  withCallingHandlers(
    log = function(cnd) {
      cat(
        "[", cnd$level, "] ", cnd$message, "\n", sep = "",
        file = path, append = TRUE
      )
    },
    expr
  )
}

record_log(log("Hello"))
#> [info] Hello

Incluso podría imaginar la superposición con otra función que le permita suprimir selectivamente algunos niveles de registro.

ignore_log_levels <- function(expr, levels) {
  withCallingHandlers(
    log = function(cnd) {
      if (cnd$level %in% levels) {
        cnd_muffle(cnd)
      }
    },
    expr
  )
}

record_log(ignore_log_levels(log("Hello"), "info"))

Si crea un objeto de condición a mano y lo señala con signalCondition(), cnd_muffle() no funcionará. En su lugar, debe llamarlo con un reinicio de mufla definido, así:

withRestarts(signalCondition(cond), muffle = function() NULL)

Los reinicios están actualmente fuera del alcance del libro, pero sospecho que se incluirán en la tercera edición.

8.6.6 Ejercicios

  1. Cree suppressConditions() que funcione como suppressMessages() y suppressWarnings() pero suprima todo. Piense cuidadosamente acerca de cómo debe manejar los errores.

  2. Compare las siguientes dos implementaciones de message2error(). ¿Cuál es la principal ventaja de withCallingHandlers() en este escenario? (Sugerencia: mire cuidadosamente el rastreo).

    message2error <- function(code) {
      withCallingHandlers(code, message = function(e) stop(e))
    }
    message2error <- function(code) {
      tryCatch(code, message = function(e) stop(e))
    }
  3. ¿Cómo modificaría la definición de catch_cnds() si quisiera recrear la combinación original de advertencias y mensajes?

  4. ¿Por qué es peligroso atrapar interrupciones? Ejecute este código para averiguarlo.

    bottles_of_beer <- function(i = 99) {
      message(
        "There are ", i, " bottles of beer on the wall, ", 
        i, " bottles of beer."
      )
      while(i > 0) {
        tryCatch(
          Sys.sleep(1),
          interrupt = function(err) {
            i <<- i - 1
            if (i > 0) {
              message(
                "Take one down, pass it around, ", i, 
                " bottle", if (i > 1) "s", " of beer on the wall."
              )
            }
          }
        )
      }
      message(
        "No more bottles of beer on the wall, ", 
        "no more bottles of beer."
      )
    }

8.7 Respuestas de la prueba

  1. error, warning, y message.

  2. Podrías usar try() o tryCatch().

  3. tryCatch() crea controladores existentes que finalizarán la ejecución del código envuelto; withCallingHandlers() crea controladores de llamadas que no afectan la ejecución del código envuelto.

  4. Porque luego puede capturar tipos específicos de error con tryCatch(), en lugar de depender de la comparación de cadenas de errores, lo cual es arriesgado, especialmente cuando se traducen los mensajes.


  1. El final . en call. es una peculiaridad de stop(); no leas nada.↩︎

  2. Pero tenga en cuenta que cat() requiere un final explícito "\n" para imprimir una nueva línea.↩︎

  3. Puede suprimir el mensaje con try(..., silent = TRUE).↩︎

  4. Puede saber si la expresión falló porque el resultado tendrá clase try-error.↩︎