Programando en Ruby

La Guía de los "Programadores Pragmáticos"

Excepciones, Captura, y Lanzamiento



Hasta el momento, hemos desarrollado código en Pleasantville, un mágico lugar donde nada va nunca mal. Todas las llamadas a funciones son exitosas, los usuarios nunca entran datos incorrectos y los recursos son abundantes y baratos. Bien, eso está a punto de cambiar. ¡Bienvenido al mundo real!

En el mundo real, hay errores. Los buenos programas (y programadores) los anticipan para manejarlos con elegancia. Esto no es siempre tan sencillo como debería. A menudo el código que detecta un error no conoce el contexto para saber qué hacer. Por ejemplo, intentar abrir un fichero que no existe es posible en algunas circunstancias y fatal en otras. ¿Qué debe hacer al respecto tu módulo de manejo de ficheros?

La solución tradicional consiste en usar códigos de retorno. El método open devuelve un valor específico para indicar que ha fallado. Este valor se propaga hacia atrás a través de las sucesivas llamadas, hasta que alguna función se hace responsable de él.

El problema de esta solución es que manejar todos los posibles códigos de error es penoso. Si una función llama a open, luego a read y por último a close, y todas las llamadas pueden devolver un indicador de error, ¿cómo puede la función distinguir estos códigos de error en el valor que retorna al código que la ha llamado?

De forma general, las excepciones resuelven este problema. Permiten empaquetar la información relativa a un error en un objeto. Dicho objeto es entonces propagado hacia atrás en la pila de llamadas automáticamente hasta que el sistema encuentra código que declara de forma explícita cómo manejar ese tipo de excepción.

La Clase Exception

El paquete que contiene información sobre una excepción es un objeto de la clase Exception, o alguna de sus clases herederas. Ruby predefine una precisa jerarquía de excepciones, mostrada en la Figura 8.1 de la página XX. Como veremos más adelante, dicha jerarquía transforma el manejo de excepciones en una tarea mucho más sencilla.

Figura no disponible...

Cuando necesites disparar una excepción, puedes usar una de las clases Exception por defecto, o puedes crear una tú mismo. Si creas una, quizá quieras que herede de StandardError o alguna de sus hijas. De no hacerlo así, tu excepción no será capturada por defecto.

Cada Exception tiene asociados un mensaje y una pila de llamadas. Si defines tus propias excepciones, puedes añadir información adicional.

Manejando Excepciones

Nuestra gramola descarga canciones de Internet usando una conexión TCP. El códido es sencillo:

opFile = File.open(opName, "w")
while data = socket.read(512)
  opFile.write(data)
end

¿Qué ocurre si obtenemos un error fatal en mitad de la descarga? Desde luego, no queremos guardar una canción incompleta en la lista de canciones. ``I Did It My *click*''.

Vamos a añadir al código manejo de excepciones y veremos cómo puede ayudarnos. Insertamos el código que dispara la excepción en un bloque begin/end y usamos cláusulas rescue para informar a Ruby de los tipos de excepciones que queremos manejar. En este caso, estamos interesados en atrapar excepciones SystemCallError (y, por extensión, cualquier excepción que sea una subclase de SystemCallError), así que eso es lo que aparece en la línea rescue. En el bloque que maneja la excepción, anunciamos el error, cerramos y borramos el fichero de salida y relanzamos la excepción.

opFile = File.open(opName, "w")
begin
  # Exceptions raised by this code will
  # be caught by the following rescue clause
  while data = socket.read(512)
    opFile.write(data)
  end

rescue SystemCallError   $stderr.print "IO failed: " + $!   opFile.close   File.delete(opName)   raise end

Cuando se lanza una excepción, al margen de cualquier subsiguiente manejo, Ruby pone una referencia al objeto Exception asociado en la variable global $! (el punto de exclamación, presumiblemente, denota la sorpresa fruto de constatar que nuestro código podría causar errores). En el ejemplo previo, usamos dicha variable para formatear el mensaje de error.

Tras cerrar y borrar el fichero, llamamos a raise sin parámetros para relanzar la excepción contenida en $!. Esta técnica es útil, pues permite filtrar excepciones, pasando aquellas que no puedes manejar aún a niveles superiores. Es casi como implementar una jerarquía de herencia para el procesado de errores.

Puede haber múltiples cláusulas rescue en un bloque begin, y cada una puede especificar múltiples excepciones a atrapar. Al final de cada cláusula, puedes dar a Ruby el nombre de una variable local que reciba la excepción específica. Mucha gente encuentra esto más legible que usar $! por sistema.

begin
  eval string
rescue SyntaxError, NameError => boom
  print "String doesn't compile: " + boom
rescue StandardError => bang
  print "Error running script: " + bang
end

¿Cómo decide Ruby qué cláusula rescue ejecutar? El proceso es bastante similar al usado por la sentencia case. Para cada cláusula rescue en un bloque begin, Ruby compara la excepción lanzada con cada uno de los parámetros por turno. Si se produce una coincidencia, ejecuta el cuerpo de dicho rescue y pára de mirar. La concordancia se busca usando $!.kind_of?(parámetro), de forma que tendrá éxito si el parámetro es de la misma clase que la excepción o si es un ancestro. Si escribes una cláusula rescue sin lista de parámetros, se usará como valor por defecto StandardError.

Si ninguna cláusula rescue coincide, o si se lanza una excepción fuera de un bloque begin/end, Ruby asciende en la pila y busca un manejador de excepciones en la función que ha efectuado la llamada, y luego en la que ha llamado a esta última, y así sucesivamente.

Aunque los parámetros de una cláusula rescue normalmente son los nombres de clases Exception, en realidad pueden ser expresiones arbitrarias (incluyendo llamadas a métodos) que retornen una clase Exception.

Poniendo orden

A veces necesitas garantizar que un proceso se realiza al final de un bloque de código, independientemente de si se ha disparado o no una excepción. Por ejemplo, puedes tener un fichero abierto que debe ser cerrado al salir del bloque.

La cláusula ensure se encarga justo de esto. ensure va después del último rescue y contiene un fragmento de código que se ejecutará siempre cuando el bloque termine. No importa si el bloque finaliza normalmente, si lanza y atrapa una excepción, o si se produce una excepción que no sabe manejar. El bloque ensure se ejecutará.

f = File.open("testfile")
begin
  # .. process
rescue
  # .. handle error
ensure
  f.close unless f.nil?
end

La cláusula else, aunque menos útil, tiene su valor. De estar presente, va después de las cláusulas rescue y antes de ensure. El cuerpo de un else se ejecuta sólo si no se lanza ninguna excepción en el bloque principal de código.

f = File.open("testfile")
begin
  # .. procesa
rescue
  # .. maneja error
else
  puts "Felicidades-- ¡no hay  errores!"
ensure
  f.close unless f.nil?
end

Tócala otra vez

A veces se da la posibilidad de corregir la causa de una excepción. En dichos casos, cabe usar la sentencia retry dentro de una cláusula rescue para repetir el bloque begin/end completo. Ya que este hecho conlleva un claro riesgo de meterse en bucles infinitos, es conveniente usarlo con precaución (y con un dedo acariciando la tecla de interrupción).

Como ejemplo de código que contiene excepciones, echa un vistazo a lo siguiente, adaptado de la biblioteca net/smtp.rb de Minero Aoki.

@esmtp = true

begin   # First try an extended login. If it fails because the   # server doesn't support it, fall back to a normal login

  if @esmtp then     @command.ehlo(helodom)   else     @command.helo(helodom)   end

rescue ProtocolError   if @esmtp then     @esmtp = false     retry   else     raise   end end

Este código intenta primero conectar a un servidor SMTP usando el comando EHLO, que no está soportado universalmente. Si el intento de conexión falla, el código da a la variable @esmtp el valor false y reintenta la conexión. Si falla de nuevo, la excepción es relanzada a la función que ha llamado al código.

Lanzando excepciones

Hasta ahora hemos estado a la defensiva, manejando excepciones lanzadas por otros. Es hora de cambiar las tornas y pasar a la ofensiva. (Algunos dirán que estos amables autores resultan siempre ofensivos, pero ese es otro asunto aparte.)

Puedes lanzar excepciones en tu código con el método Kernel::raise .

raise
raise "bad mp3 encoding"
raise InterfaceException, "Keyboard failure", caller

La primera forma, simplemente relanza la excepción actual (o, si no existe esta, RuntimeError). Se usa en manejadores de excepción que necesitan interceptar una excepción antes de pasarla a otro nivel.

La segunda forma crea una nueva excepción RuntimeError, estableciendo su mensaje a la cadena dada. Esta es lanzada a la pila de llamadas.

La tercera forma usa el primer argumento para crear una excepción y establece el segundo argumento como mensaje asociado y el tercer argumento como traza de la pila. Por lo general, el primer argumento será, o bien el nombre de una clase en la jerarquía Exception, o una referencia al objeto de una de dichas clases.[Técnicamente, puede ser cualquier objeto que responda al mensaje exception retornando otro objeto tal que object.kind_of?(Exception) sea verdadero.] La traza de la pila se produce normalmente usando el método Kernel::caller .

He aquí algunos ejemplos típicos de raise en acción.

raise

raise "Missing name" if name.nil?

if i >= myNames.size   raise IndexError, "#{i} >= size (#{myNames.size})" end

raise ArgumentError, "Name too big", caller

En el último ejemplo, quitamos la rutina actual de la pila de ejecución, lo cuál es a menudo útil en módulos de biblioteca. Podemos ir más lejos: el código siguiente quita dos rutinas de la cola de ejecución.

raise ArgumentError, "Name too big", caller[1..-1]

Añadir Información a las Excepciones

Puedes definir que tus propias excepciones contengan cualquier información que necesites pasar desde el sitio en que se dispara el error. Por ejemplo, ciertos tipos de errores de red pueden ser temporales dependiendo de las circunstancias. Si un error así ocurre, y la situación es propicia, puedes poner un indicador en la excepción para informar al manejador de que puede merecer la pena reintentar la operación.

class RetryException < RuntimeError
  attr :okToRetry
  def initialize(okToRetry)
    @okToRetry = okToRetry
  end
end

En algún lugar de las profundidades del código, un error temporal ocurre.

def readData(socket)
  data = socket.read(512)
  if data.nil?
    raise RetryException.new(true), "transient read error"
  end
  # .. normal processing
end

Más arriba en la pila de llamadas, manejamos la excepción.

begin
  stuff = readData(socket)
  # .. process stuff
rescue RetryException => detail
  retry if detail.okToRetry
  raise
end

Catch y Throw

Aunque el mecanismo de excepción de raise y rescue es adecuado para abandonar la ejecución cuando las cosas van mal, a veces es conveniente poder saltar fuera de una construcción profundamente anidada durante el procesamiento normal. Es aquí donde catch y throw se tornan útiles.

catch (:done)  do
  while gets
    throw :done unless fields = split(/\t/)
    songList.add(Song.new(*fields))
  end
  songList.play
end

catch define un bloque al que se asigna un determinado nombre (que puede ser un Symbol o un String). El bloque se ejecuta normalmente hasta que un throw es encontrado.

Cuando Ruby encuentra un throw, recorre la pila de llamadas hacia atrás a la caza de un bloque catch coincidente. Cuando lo encuentra, Ruby retorna la pila a ese punto y finaliza el bloque. Si el throw es llamado con un segundo parámetro opcional, dicho valor se devuelve como el valor de catch. Así, en el ejemplo previo, si la entrada no contiene líneas correctamente formateadas, el throw saltará al final del correspondiente catch, no sólo finalizando el bucle while, sino evitando, además, reproducir la lista de canciones.

El siguiente ejemplo usa un throw para finalizar la interacción con el usuario si ``!'' se introduce como respuesta a cualquier petición de datos.

def promptAndGet(prompt)
  print prompt
  res = readline.chomp
  throw :quitRequested if res == "!"
  return res
end

catch :quitRequested do   name = promptAndGet("Name: ")   age  = promptAndGet("Age:  ")   sex  = promptAndGet("Sex:  ")   # ..   # process information end

Como ilustra el ejemplo, throw no tiene por qué aparecer dentro del ámbito estático de catch.


Extraído del libro "Programming Ruby - The Pragmatic Programmer's Guide"
Copyright © 2000 Addison Wesley Longman, Inc. liberado bajo los términos de la Open Publication License V1.0.
La referencia está disponible en: download.