22  Depuración

22.1 Introducción

¿Qué haces cuando el código R arroja un error inesperado? ¿Qué herramientas tienes para encontrar y solucionar el problema? Este capítulo le enseñará el arte y la ciencia de la depuración, comenzando con una estrategia general y luego siguiendo con herramientas específicas.

Mostraré las herramientas proporcionadas por R y el IDE de RStudio. Recomiendo usar las herramientas de RStudio si es posible, pero también te mostraré los equivalentes que funcionan en todas partes. También puede consultar la documentación oficial de depuración de RStudio que siempre refleja la última versión de RStudio.

NB: No debería necesitar usar estas herramientas al escribir funciones nuevas. Si se encuentra usándolos con frecuencia con código nuevo, reconsidere su enfoque. En lugar de tratar de escribir una gran función de una sola vez, trabaje de forma interactiva en piezas pequeñas. Si comienza poco a poco, puede identificar rápidamente por qué algo no funciona y no necesita herramientas de depuración sofisticadas.

Estructura

  • La Sección 22.2 describe una estrategia general para encontrar y corregir errores.

  • La Sección 22.3 le presenta la función traceback() que le ayuda a localizar exactamente dónde ocurrió un error.

  • La Sección 22.4 le muestra cómo pausar la ejecución de una función e iniciar un entorno donde puede explorar de forma interactiva lo que está sucediendo.

  • La Sección 22.5 analiza el desafiante problema de la depuración cuando ejecuta código de forma no interactiva.

  • La Sección 22.6 analiza un puñado de problemas que no son errores y que ocasionalmente también necesitan depuración.

22.2 Enfoque global

Encontrar su error es un proceso de confirmación de las muchas cosas que cree que son ciertas, hasta que encuentre una que no lo sea.

—Norm Matloff

Encontrar la causa raíz de un problema siempre es un desafío. La mayoría de los errores son sutiles y difíciles de encontrar porque si fueran obvios, los habrías evitado en primer lugar. Una buena estrategia ayuda. A continuación, describo un proceso de cuatro pasos que he encontrado útil:

  1. Google!

    Cada vez que vea un mensaje de error, comience a buscarlo en Google. Si tiene suerte, descubrirá que es un error común con una solución conocida. Cuando busque en Google, mejore sus posibilidades de una buena coincidencia eliminando cualquier nombre o valor de variable que sea específico para su problema.

    Puede automatizar este proceso con los paquetes errorist (Balamuta 2018a) y searcher (Balamuta 2018b). Consulte sus sitios web para obtener más detalles.

  2. Hazlo repetible

    Para encontrar la causa raíz de un error, necesitará ejecutar el código muchas veces mientras considera y rechaza las hipótesis. Para que la iteración sea lo más rápida posible, vale la pena hacer una inversión inicial para que el problema sea fácil y rápido de reproducir.

    Comience creando un ejemplo reproducible (Sección 1.7). A continuación, haga que el ejemplo sea mínimo eliminando el código y simplificando los datos. Al hacer esto, es posible que descubra entradas que no desencadenan el error. Tome nota de ellos: serán útiles al diagnosticar la causa raíz.

    Si está utilizando pruebas automatizadas, este también es un buen momento para crear un caso de prueba automatizado. Si su cobertura de prueba existente es baja, aproveche la oportunidad para agregar algunas pruebas cercanas para garantizar que se conserve el buen comportamiento existente. Esto reduce las posibilidades de crear un nuevo error.

  3. Averigua dónde está

    Si tiene suerte, una de las herramientas de la siguiente sección lo ayudará a identificar rápidamente la línea de código que está causando el error. Por lo general, sin embargo, tendrás que pensar un poco más sobre el problema. Es una gran idea adoptar el método científico. Genere hipótesis, diseñe experimentos para probarlas y registre sus resultados. Esto puede parecer mucho trabajo, pero un enfoque sistemático terminará ahorrándole tiempo. A menudo pierdo mucho tiempo confiando en mi intuición para resolver un error (“oh, debe ser un error de uno, así que restaré 1 aquí”), cuando hubiera sido mejor tomar un Acercamiento sistematico.

    Si esto falla, es posible que deba pedir ayuda a otra persona. Si ha seguido el paso anterior, tendrá un pequeño ejemplo que es fácil de compartir con otros. Eso hace que sea mucho más fácil para otras personas ver el problema y es más probable que lo ayuden a encontrar una solución.

  4. Arreglarlo y probarlo

    Una vez que haya encontrado el error, debe descubrir cómo solucionarlo y verificar que la solución realmente funcionó. Una vez más, es muy útil contar con pruebas automatizadas. Esto no solo ayuda a garantizar que realmente haya solucionado el error, sino que también ayuda a garantizar que no haya introducido ningún error nuevo en el proceso. En ausencia de pruebas automatizadas, asegúrese de registrar cuidadosamente la salida correcta y compárela con las entradas que fallaron anteriormente.

22.3 Localización de errores

Una vez que haya hecho que el error sea repetible, el siguiente paso es averiguar de dónde viene. La herramienta más importante para esta parte del proceso es traceback(), que le muestra la secuencia de llamadas (también conocida como pila de llamadas, Sección 7.5) que conducen al error.

He aquí un ejemplo sencillo: puedes ver que f() llama a g() llama a h() llama a i(), que comprueba si su argumento es numérico:

f <- function(a) g(a)
g <- function(b) h(b)
h <- function(c) i(c)
i <- function(d) {
  if (!is.numeric(d)) {
    stop("`d` must be numeric", call. = FALSE)
  }
  d + 10
}

Cuando ejecutamos el código f("a") en RStudio vemos:

Aparecen dos opciones a la derecha del mensaje de error: “Mostrar seguimiento” y “Volver a ejecutar con depuración”. Si hace clic en “Mostrar seguimiento”, verá:

Si no está usando RStudio, puede usar traceback() para obtener la misma información (sin un formato bonito):

traceback()
#> 5: stop("`d` must be numeric", call. = FALSE) at debugging.R#6
#> 4: i(c) at debugging.R#3
#> 3: h(b) at debugging.R#2
#> 2: g(a) at debugging.R#1
#> 1: f("a")

NB: Usted lee la salida traceback() de abajo hacia arriba: la llamada inicial es f(), que llama g(), luego h(), luego i(), que activa el error. Si está llamando al código que ’fuente () ’d en R, el rastreo también mostrará la ubicación de la función, en la forma nombredearchivo.r# númerodelínea. Estos se pueden hacer clic en RStudio y lo llevarán a la línea de código correspondiente en el editor.

22.3.1 Evaluación perezosa

Un inconveniente de traceback() es que siempre linealiza el árbol de llamadas, lo que puede ser confuso si hay mucha evaluación perezosa involucrada (Sección 7.5.2). Por ejemplo, tome el siguiente ejemplo donde ocurre el error al evaluar el primer argumento de f():

j <- function() k()
k <- function() stop("Oops!", call. = FALSE)
f(j())
#> Error: Oops!
traceback()
#> 7: stop("Oops!") at #1
#> 6: k() at #1
#> 5: j() at debugging.R#1
#> 4: i(c) at debugging.R#3
#> 3: h(b) at debugging.R#2
#> 2: g(a) at debugging.R#1
#> 1: f(j())

Puede usar rlang::with_abort() y rlang::last_trace() para ver el árbol de llamadas. Aquí, creo que hace que sea mucho más fácil ver el origen del problema. Mire la última rama del árbol de llamadas para ver que el error proviene de j() llamando a k().

rlang::with_abort(f(j()))
#> Error: 'with_abort' is not an exported object from 'namespace:rlang'
rlang::last_trace()
#> Error: Can't show last error because no error was recorded yet

NB: rlang::last_trace() se ordena de forma opuesta a traceback(). Volveremos a ese tema en la Sección 22.4.2.4.

22.4 Depurador interactivo

A veces, la ubicación precisa del error es suficiente para permitirle localizarlo y solucionarlo. Sin embargo, con frecuencia necesita más información, y la forma más fácil de obtenerla es con el depurador interactivo que le permite pausar la ejecución de una función y explorar su estado de forma interactiva.

Si está utilizando RStudio, la forma más fácil de ingresar al depurador interactivo es a través de la herramienta “Reejecutar con depuración” de RStudio. Esto vuelve a ejecutar el comando que creó el error, deteniendo la ejecución donde ocurrió el error. De lo contrario, puede insertar una llamada a browser() donde desea hacer una pausa y volver a ejecutar la función. Por ejemplo, podríamos insertar una llamada browser() en g():

g <- function(b) {
  browser()
  h(b)
}
f(10)

browser() es solo una llamada de función regular, lo que significa que puede ejecutarla condicionalmente envolviéndola en una declaración if:

g <- function(b) {
  if (b < 0) {
    browser()
  }
  h(b)
}

En cualquier caso, terminará en un entorno interactivo dentro de la función donde puede ejecutar código R arbitrario para explorar el estado actual. Sabrá cuándo está en el depurador interactivo porque recibe un aviso especial:

Browse[1]> 

En RStudio, verá el código correspondiente en el editor (con la instrucción que se ejecutará a continuación resaltada), los objetos en el entorno actual en el panel Entorno y la pila de llamadas en el panel Rastreo.

22.4.1 Comandos browser()

Además de permitirle ejecutar código R regular, browser() proporciona algunos comandos especiales. Puede usarlos escribiendo comandos de texto cortos o haciendo clic en un botón en la barra de herramientas de RStudio, Figura 22.1:

Figura 22.1: RStudio debugging toolbar
  • Siguiente, n: ejecuta el siguiente paso en la función. Si tiene una variable llamada n, necesitará print(n) para mostrar su valor.

  • Entrar en, o s: funciona como el siguiente, pero si el siguiente paso es una función, entrará en esa función para que pueda explorarla de forma interactiva.

  • Finalizar, o f: finaliza la ejecución del ciclo o función actual.

  • Continuar, c: sale de la depuración interactiva y continúa con la ejecución normal de la función. Esto es útil si ha solucionado el mal estado y desea comprobar que la función se desarrolla correctamente.

  • Detener, Q: detiene la depuración, finaliza la función y regresa al espacio de trabajo global. Úselo una vez que haya descubierto dónde está el problema y esté listo para solucionarlo y volver a cargar el código.

Hay otros dos comandos un poco menos útiles que no están disponibles en la barra de herramientas:

  • Enter: repite el comando anterior. Encuentro esto demasiado fácil de activar accidentalmente, así que lo apago usando options(browserNLdisabled = TRUE).

  • where: imprime el seguimiento de la pila de llamadas activas (el equivalente interactivo de traceback).

22.4.2 Alternativas

Hay tres alternativas al uso de browser(): establecer puntos de interrupción en RStudio, options(error = recovery) y debug() y otras funciones relacionadas.

22.4.2.1 Puntos de ruptura

En RStudio, puede establecer un punto de interrupción haciendo clic a la izquierda del número de línea o presionando Shift + F9. Los puntos de interrupción se comportan de manera similar a browser() pero son más fáciles de configurar (un clic en lugar de nueve pulsaciones de teclas), y no corre el riesgo de incluir accidentalmente una declaración browser() en su código fuente. Hay dos pequeñas desventajas de los puntos de interrupción:

22.4.2.2 recover()

Otra forma de activar browser() es usar options(error = recover). Ahora, cuando reciba un error, obtendrá un mensaje interactivo que muestra el rastreo y le brinda la capacidad de depurar de forma interactiva dentro de cualquiera de los marcos:

options(error = recover)
f("x")
#> Error: `d` must be numeric
#> 
#> Enter a frame number, or 0 to exit   
#> 
#> 1: f("x")
#> 2: debugging.R#1: g(a)
#> 3: debugging.R#2: h(b)
#> 4: debugging.R#3: i(c)
#> 
#> Selection:

Puede volver al manejo de errores predeterminado con options(error = NULL).

22.4.2.3 debug()

Otro enfoque es llamar a una función que inserta la llamada browser() por ti:

  • debug() inserta una declaración del navegador en la primera línea de la función especificada. undebug() lo elimina. Alternativamente, puede usar debugonce() para navegar solo en la próxima ejecución.

  • utils::setBreakpoint() funciona de manera similar, pero en lugar de tomar un nombre de función, toma un nombre de archivo y un número de línea y encuentra la función adecuada para usted.

Estas dos funciones son casos especiales de trace(), que inserta código arbitrario en cualquier posición de una función existente. trace() es ocasionalmente útil cuando estás depurando código para el cual no tienes la fuente. Para eliminar el rastreo de una función, use untrace(). Solo puede realizar un seguimiento por función, pero ese seguimiento puede llamar a varias funciones.

22.4.2.4 Pila de llamadas

Desafortunadamente, las pilas de llamadas impresas por traceback(), browser() & where, y recover() no son consistentes. La siguiente tabla muestra cómo las tres herramientas muestran las pilas de llamadas de un conjunto anidado simple de llamadas. La numeración es diferente entre traceback() y where, y recover() muestra las llamadas en el orden opuesto.

traceback() where recover() funciones rlang
5: stop("...")
4: i(c) where 1: i(c) 1: f() 1. └─global::f(10)
3: h(b) where 2: h(b) 2: g(a) 2. └─global::g(a)
2: g(a) where 3: g(a) 3: h(b) 3. └─global::h(b)
1: f("a") where 4: f("a") 4: i("a") 4. └─global::i("a")

RStudio muestra las llamadas en el mismo orden que traceback(). Las funciones rlang usan el mismo orden y numeración que recover(), pero también usan sangría para reforzar la jerarquía de las llamadas.

22.4.3 Código compilado

También es posible usar un depurador interactivo (gdb o lldb) para código compilado (como C o C++). Desafortunadamente, eso está más allá del alcance de este libro, pero hay algunos recursos que pueden resultarle útiles:

22.5 Depuración no interactiva

La depuración es más desafiante cuando no puede ejecutar el código de forma interactiva, generalmente porque es parte de una canalización que se ejecuta automáticamente (posiblemente en otra computadora), o porque el error no ocurre cuando ejecuta el mismo código de forma interactiva. ¡Esto puede ser extremadamente frustrante!

Esta sección le brindará algunas herramientas útiles, pero no olvide la estrategia general en la Sección 22.2. Cuando no puede explorar de forma interactiva, es particularmente importante dedicar algún tiempo a hacer que el problema sea lo más pequeño posible para que pueda iterar rápidamente. A veces callr::r(f, list(1, 2)) puede ser útil; esto llama a f(1, 2) en una nueva sesión y puede ayudar a reproducir el problema.

También es posible que desee verificar dos veces estos problemas comunes:

  • ¿Es diferente el entorno global? ¿Has cargado diferentes paquetes? ¿Los objetos que quedaron de sesiones anteriores causan diferencias?

  • ¿El directorio de trabajo es diferente?

  • ¿Es diferente la variable de entorno PATH, que determina dónde se encuentran los comandos externos (como git)?

  • ¿Es diferente la variable de entorno R_LIBS, que determina dónde library() busca paquetes?

22.5.1 dump.frames()

dump.frames() es el equivalente a recover() para código no interactivo; guarda un archivo last.dump.rda en el directorio de trabajo. Más tarde, en una sesión interactiva, puede load("last.dump.rda"); debugger() para ingresar a un depurador interactivo con la misma interfaz que recover(). Esto le permite “engañar”, depurando de forma interactiva el código que se ejecutó de forma no interactiva.

# En proceso por lotes R ----
dump_and_quit <- function() {
  # Guardar información de depuración en un archivo last.dump.rda
  dump.frames(to.file = TRUE)
  # Salir de R con estado de error
  q(status = 1)
}
options(error = dump_and_quit)

# En una sesión interactiva posterior ----
load("last.dump.rda")
debugger()

22.5.2 Imprimir depuración

Si dump.frames() no ayuda, una buena alternativa es imprimir la depuración, donde inserta numerosas instrucciones de impresión para ubicar con precisión el problema y ver los valores de las variables importantes. La depuración de impresión es lenta y primitiva, pero siempre funciona, por lo que es particularmente útil si no puede obtener un buen seguimiento. Comience insertando marcadores de grano grueso y luego hágalos progresivamente más finos a medida que determina exactamente dónde está el problema.

f <- function(a) {
  cat("f()\n")
  g(a)
}
g <- function(b) {
  cat("g()\n")
  cat("b =", b, "\n")
  h(b)
}
h <- function(c) {
  cat("i()\n")
  i(c)
}

f(10)
#> f()
#> g()
#> b = 10 
#> i()
#> [1] 20

Imprimir la depuración es particularmente útil para el código compilado porque no es raro que el compilador modifique su código hasta el punto de que no pueda descubrir la raíz del problema, incluso cuando se encuentra dentro de un depurador interactivo.

22.5.3 RMarkdown

La depuración del código dentro de los archivos RMarkdown requiere algunas herramientas especiales. Primero, si está tejiendo el archivo usando RStudio, cambie a llamar rmarkdown::render("path/to/file.Rmd") en su lugar. Esto ejecuta el código en la sesión actual, lo que facilita la depuración. Si hacer esto hace que el problema desaparezca, deberá descubrir qué hace que los entornos sean diferentes.

Si el problema persiste, deberá usar sus habilidades de depuración interactiva. Independientemente del método que utilice, necesitará un paso adicional: en el controlador de errores, deberá llamar a sink(). Esto elimina el sumidero predeterminado que usa knitr para capturar todos los resultados y garantiza que pueda ver los resultados en la consola. Por ejemplo, para usar recover() con RMarkdown, colocaría el siguiente código en su bloque de configuración:

options(error = function() {
  sink()
  recover()
})

Esto generará una advertencia de “no hay disipador para eliminar” cuando se complete knitr; puede ignorar esta advertencia con seguridad.

Si simplemente quiere un rastreo, la opción más fácil es usar rlang::trace_back(), aprovechando la opción rlang_trace_top_env. Esto garantiza que solo vea el rastreo de su código, en lugar de todas las funciones llamadas por RMarkdown y knitr.

options(rlang_trace_top_env = rlang::current_env())
options(error = function() {
  sink()
  print(rlang::trace_back(bottom = sys.frame(-1)), simplify = "none")
})

22.6 Fallos sin error

Hay otras formas de que una función falle además de arrojar un error:

  • Una función puede generar una advertencia inesperada. La forma más fácil de rastrear las advertencias es convertirlas en errores con options(warn = 2) y usar la pila de llamadas, como doWithOneRestart(), withOneRestart(), herramientas de depuración regulares. Cuando haga esto, verá algunas llamadas adicionales withRestarts() y .signalSimpleWarning(). Ignórelos: son funciones internas que se utilizan para convertir las advertencias en errores.

  • Una función puede generar un mensaje inesperado. Puedes usar rlang::with_abort() para convertir estos mensajes en errores:

    f <- function() g()
    g <- function() message("Hi!")
    f()
    #> Hi!
    
    rlang::with_abort(f(), "message")
    #> Error: 'with_abort' is not an exported object from 'namespace:rlang'
    rlang::last_trace()
    #> Error: Can't show last error because no error was recorded yet
  • Es posible que una función nunca regrese. Esto es particularmente difícil de depurar automáticamente, pero a veces terminar la función y mirar el traceback() es informativo. De lo contrario, utilice la depuración de impresión, como en la Sección 22.5.2.

  • El peor de los escenarios es que su código podría fallar por completo en R, dejándolo sin forma de depurar su código de manera interactiva. Esto indica un error en el código compilado (C o C++).

    Si el error está en su código compilado, deberá seguir los enlaces en la Sección 22.4.3 y aprender a usar un depurador de C interactivo (o insertar muchas instrucciones de impresión).

    Si el error está en un paquete o base R, deberá ponerse en contacto con el mantenedor del paquete. En cualquier caso, trabaje para hacer el ejemplo reproducible más pequeño posible (Sección 1.7) para ayudar al desarrollador a ayudarlo.