Introducción a la programación en Python
clase 12

Errores y excepciones

Siempre que escribimos un programa vamos a encontrar algún tipo de error. Puede que el programa no haga estricatamente aquello para lo cual fue diseñado, o que no se hayan previsto ciertos escenarios de ejecución posibles. En principio, podemos considerar al menos dos tipos bien diferenciados de errores: errores de sintaxis y excepciones.

Errores de sintaxis

Los errores de sintaxis son probablemente los más comunes, principalmente cuando aún se está aprendiendo el lenguaje. Son detectados al analizar sintácticamente las instrucciones del programa, antes de ejecutarlas. De hecho, una instrucción que presenta un error de sintaxis no puede ejecutarse. Veamos un ejemplo a continuación.

In [2]:
while True print("Error de sintaxis")
  File "<ipython-input-2-ed4df922cbcc>", line 1
    while True print("Error de sintaxis")
                   ^
SyntaxError: invalid syntax

El intérprete repite la línea que genera el error y se indica con una flecha el punto más cercano a su detección (en este caso la función print dado que falta los dos puntos ':' inmediatamente antes). También se indica el archivo y el número de línea para ubicar el error dentro de un programa.

Excepciones

Aún cuando una sentencia es sintácticamente correcta, puede generar un error al ser ejecutada. Los errores que se detectan durante la ejecución se conocen como excepciones. Si no se manejan correctamente hacen que el programa no pueda seguir funcionando y se fuerza la salida del flujo de ejecución. Existen mecanismos previstos en el lenguaje Python para manejar las excepciones adecuadamente, lo que puede proporcionar acciones para mitigar los efectos y permitir que el programa siga funcionando. En algunos casos los errores son irremediables, pero aún así, el manejo de excepciones permite salir del programa de forma más elegante, disminuyendo los daños.

Los siguientes ejemplos muestran dos tipos de excepción habituales, y lo que ocurre cuando no son manejadas.

In [3]:
10 * (1/0)
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-3-9ce172bd90a7> in <module>()
----> 1 10 * (1/0)

ZeroDivisionError: division by zero

La excepciones son de diferente tipo, por ejemplo en este caso del tipo ZeroDivisionError, que corresponde a la división por cero.

In [5]:
4 + spam * 3
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-5-0694da4a9aac> in <module>()
----> 1 4 + spam * 3

NameError: name 'spam' is not defined

En este caso, la excepción es del tipo NameError, que identifica un nombre no definido. El resto de la línea agrega detalles sobre el tipo de excepción y qué la generó. Uno de los elementos más importantes de esta información es la pila de invocación (el stack), es decir, la sucesión de llamadas a funciones involucradas.

Como se puede ver, existen excepciones definidas en el lenguaje (denominadas incorporadas o built-in) que pueden ser generadas por el intérprete o por funciones propias del lenguaje. Se organizan en una estructura jerárquica tal como se muestra a continuación.

In [7]:
%load exceptions.txt
In []:
BaseException
 +-- SystemExit
 +-- KeyboardInterrupt
 +-- GeneratorExit
 +-- Exception
      +-- StopIteration
      +-- ArithmeticError
      |    +-- FloatingPointError
      |    +-- OverflowError
      |    +-- ZeroDivisionError
      +-- AssertionError
      +-- AttributeError
      +-- BufferError
      +-- EOFError
      +-- ImportError
      +-- LookupError
      |    +-- IndexError
      |    +-- KeyError
      +-- MemoryError
      +-- NameError
      |    +-- UnboundLocalError
      +-- OSError
      |    +-- BlockingIOError
      |    +-- ChildProcessError
      |    +-- ConnectionError
      |    |    +-- BrokenPipeError
      |    |    +-- ConnectionAbortedError
      |    |    +-- ConnectionRefusedError
      |    |    +-- ConnectionResetError
      |    +-- FileExistsError
      |    +-- FileNotFoundError
      |    +-- InterruptedError
      |    +-- IsADirectoryError
      |    +-- NotADirectoryError
      |    +-- PermissionError
      |    +-- ProcessLookupError
      |    +-- TimeoutError
      +-- ReferenceError
      +-- RuntimeError
      |    +-- NotImplementedError
      +-- SyntaxError
      |    +-- IndentationError
      |         +-- TabError
      +-- SystemError
      +-- TypeError
      +-- ValueError
      |    +-- UnicodeError
      |         +-- UnicodeDecodeError
      |         +-- UnicodeEncodeError
      |         +-- UnicodeTranslateError
      +-- Warning
           +-- DeprecationWarning
           +-- PendingDeprecationWarning
           +-- RuntimeWarning
           +-- SyntaxWarning
           +-- UserWarning
           +-- FutureWarning
           +-- ImportWarning
           +-- UnicodeWarning
           +-- BytesWarning
           +-- ResourceWarning

Manejo de excepciones

Es posible escribir programas que manejen las excepciones deseadas. El siguiente ejemplo muestra como manejar la excepción ValueError.

In [2]:
while True:
        try:
            x = int(input("Ingrese un número: "))
            break
        except ValueError:
            print("Opa! Esto no es un número válido.  Intente nuevamente ...")
Ingrese un número: we
Opa! Esto no es un número válido.  Intente nuevamente ...
Ingrese un número: a
Opa! Esto no es un número válido.  Intente nuevamente ...
Ingrese un número: 2

Es interesante señalar que este programa no termina nunca, mientras no se ingresa un número. La forma de cortar la ejecución es usando el mecanismo provisto por el sistema operativo, por ejemplo Control-C. De hecho, esta interrupción generada por el usuario produce una excepción del tipo KeybordInterrupt (que no está siendo manejada).

La instrucción try funciona de la siguiente forma.

  • Primero, se ejecutan las instrucciones enmarcadas entre try y except.
  • Si no ocurre ninguna excepción, la instrucción except es ignorada y finaliza la ejecución de la instrucción try.
  • Si por el contrario, ocurre una excepción durante la ejecución del bloque try el resto de las instrucciones de dicho bloque son ignoradas. Si el tipo de excepción corresponde a la nombrada en la instrucción except, se ejecutan las instrucciones dentro de ese bloque y la ejecución continúa luego de la instrucción try.
  • Puede que se genere una excepción que no corresponda a la indicada en la instrucción except. En ese caso, se pasa a los bloques try-except anteriores (en el código actual o en el stack de invocación). Si la excepción no es manejada se termina la ejecución con un mensaje acorde, como se vió anteriormente.

Una de las principales ventajas de esta forma de manejar excepciones es que divide el código correspondiente al flujo normal del programa y el código destinado a manejar flujos alternativos o no previstos, lo que resulta más claro, ordenado y fácil de corregir.

Una instrucción try puede estar acompañada de varias instrucciones except, para especificar el manejo de diferentes excepciones. Pero se ejecutará a lo sumo un único bloque except. Además, una misma instrucción except puede indicar varios tipos de excepción, como una tupla entre paréntesis.

except (RuntimeError, TypeError, NameError):

A continuación un ejemplo con varias instrucciones except.

In [4]:
import sys

try:
    f = open('archivo.txt')
    s = f.readline()
    i = int(s.strip())
except IOError:
        print('No se pudo abrir el archivo.')
except ValueError:
    print("No se pudo convertir el dato a entero.")
except:
    print("Error inesperado:", sys.exc_info()[0])
    raise
No se pudo abrir el archivo.

La última instrucción except omite el nombre de la excepción, lo que genera que se capture cualquier tipo de excepción que ocurra. Sin embargo, si ocurre una excepción de los tipos indicados en las instrucciones except previas el código ejecutado es el correspondiente y se omite el manejo de la excepción genérica. Es por esta razón que la instrucción except no específica debe ir al final. Es importante manejar esta funcionalidad con cuidado. Uno de los usos habituales es el capturar la excepción para desplegar un mensaje y volver a lanzarla (más detalles luego) para que otro bloque de código sea el responable de manejarla adecuadamente.

El bloque try-except tiene la posibilidad de especificar una clausula else. Cuando se incluye, debe ir luego de todas las instrucciones except. Se utiliza para código que debe ser ejecutado cuando no se lanza ninguna excepción. Es diferente a agregar código dentro del bloque try-except ya que en caso de incluírlo podría generar que se capturen excepciones diferentes a las que se quiere manejar originalmente. A continuación un ejemplo de una instrucción else.

In [6]:
try:
    f = open('archivo.txt', 'r')
except IOError:
    print('No se pudo abrir el archivo.')
else:
    print('El archivo tiene ', len(f.readlines()), 'líneas.')
    f.close()
El archivo tiene  1 líneas.

El manejo de excepciones permite capturar excepciones que ocurran no solo estricamente en el código dentro de un bloque try-except si no también dentro de las funciones que sean invocadas (incluso indirectamente). A continuación un ejemplo del manejo de una excepción que ocurre dentro de una función.

In [16]:
def this_fails():
    x = 1/0

try:
    this_fails()
except ZeroDivisionError as err:
    print('Error dentro de una función:', err)
Error dentro de una función: division by zero

El bloque finally

Existe la posibilidad de especificar una clausula finally para definir tareas que deben ejecutarse bajo cualquier circunstancia, es decir, en el curso normal de ejecución o cuando ocurre alguna excepción. En el siguiente ejemplo se puede ver el comportamiento del bloque finally en diferentes situaciones.

In [10]:
def divide(x, y):
    try:
        result = x / y
    except ZeroDivisionError:
        print("División por cero!")
    else:
        print("El resultado es: ", result)
    finally:
        print("Ejecutando el bloque finally.")

# divide(2, 1)
divide(2, 0)
#divide('2', '0')
División por cero!
Ejecutando el bloque finally.

Es interesante notar que cuando se especifican dos cadenas de caracteres como entrada a la función divide ocurre una excepción (porque no está definida la operación para cadenas de caracteres). Dicha excepción no es capturada en un bloque except, pero aún en ese caso se ejecuta el código del bloque finally.

Una de las aplicaciones más habituales de un bloque finally es la de liberar recursos externos (como archivos, memoria o conecciones de red), independientemente de si el uso de dichos recursos fue exitoso.

Este tipo de acciones está predefinido en algunos casos, para liberar los recursos asociados a una cierta parte del código cuando estos ya no son necesarios. Uno de estos casos es la instrucción with que vimos anteriormente para manejo de archivos, y se muestra en el siguiente ejemplo.

In [25]:
for line in open("archivo.txt"):
    print(line, end="")
a

El problema con el código anterior es que deja el archivo abierto por un tiempo indeterminado, luego de que el código ha sido ejecutado. Esto puede no tener un impacto significativo en la mayoría de los casos, pero se puede tornar crítico en aplicaciones grandes. La instrucción with permite usar archivos (u otro tipo de objetos) en una forma que asegura que sean liberados los recursos asociados de inmediato y en forma correcta.

In [26]:
with open("archivo.txt") as f:
    for line in f:
        print(line, end="")
a

Luego de que se ejecutó la instrucción with el archivo f siempre es cerrado correctamente, incluso cuando ocurrió un error al procesar sus líneas. Los objetos que, como ocurre con los archivos, proveen de acciones predefinidas de cierre lo indican en su documentación.

Excepciones definidas por el usuario

Existe la posibilidad de que el usuario del lenguaje cree sus propias excepciones y las utilize en su propio código. Esto se hace típicamente a través de extender la clase Exception, es decir, crear nuevos tipos de excepción en la estructura jerárquica. Las nuevas excepciones deberían reflejar situaciones excepcionales que pueden darse en el contexto de la aplicación. Cuando estas situaciones ocurren, el programador puede crear una excepción y lanzarla, de modo de que el bloque de codigo destinado a ella la maneje correctamente.

Funciones (II)

Veremos a continuación dos temas adicionales referidos a funciones en Python: funciones con número variable de argumentos, y funciones anidadas.

Número variable de argumentos

Hay ocasiones en las que necesitamos crear una función que pueda recibir un número arbitrario de argumentos. En python eso puede implementarse fácilmente con los argumentos con asterisco: *arg.

En el ejemplo siguiente se puede observar el funcionamiento de una función con número variable de argumentos:

In [4]:
def funcion(*args):
    '''número variable de argumentos, a cada uno aplica la operación *=3'''
    for arg in args:
        arg *= 3
        print(arg)
    return None


v = 5   # variable numérica
s = "AB"    # cadena
t = (1, 2, 3)   # tuple
l = [1, 2, 3]   # lista

print("valor inicial de las variables:")
print(v)
print(s)
print(t)
print(l)

print("\nsalida de la función:")
funcion(v, s, t, l)

print("\nvalor de las variables después de llamar la función:")
print(v)
print(s)
print(t)
print(l)
valor inicial de las variables:
5
AB
(1, 2, 3)
[1, 2, 3]

salida de la función:
15
ABABAB
(1, 2, 3, 1, 2, 3, 1, 2, 3)
[1, 2, 3, 1, 2, 3, 1, 2, 3]

valor de las variables después de llamar la función:
5
AB
(1, 2, 3)
[1, 2, 3, 1, 2, 3, 1, 2, 3]

Se pueden observar también dos aspectos importantes:

  • el polimorfismo de los operadores en Python (en este caso, *=), que adquieren distinto significado según el tipo de variable sobre la que operan;
  • si dentro de la función se opera sobre el valor de una variable, ésta modificará o no su valor fuera de la función dependiendo de que sea de un tipo mutable o no.

Se pueden combinar argumentos fijos con un argumento de número variable, pero en este caso el argumento de número variable debe ir al final. Sólo se puede usar un argumento de número variable en la definición de una función.

Funciones anidadas

Toda función devuelve un valor de algún tipo, y podemos hacer que devuelva una función. Es decir que podemos definir una función dentro de la definición de otra función, a lo que se le llama funciones anidadas.

In [6]:
def acorde(intervalos):
    '''define tipo de acorde con una estructura de intervalos'''
    def transporte(fundamental):
        '''transporta el tipo de acorde a una fundamental'''
        pcset = [fundamental+i for i in intervalos]
        return pcset
    return transporte

mayor = acorde([0, 4, 7])
menor = acorde([0, 3, 7])

print(mayor(60))
print(menor(60))
print(menor(66))
[60, 64, 67]
[60, 63, 67]
[66, 69, 73]