En el Tutorial Swift de hoy aprenderemos sobre el manejo de errores. Si te preguntas que es el manejo de errores pues en términos simples no es más que el uso de ciertas herramientas que nos brinda el lenguaje de programación Swift en pos de manejar y/o capturar distintos tipos de errores en tiempo de ejecución y evitar así que nuestra aplicación interrumpa su funcionamiento y termine por cerrarse de manera abrupta.

Con los controles antes comentados no solamente protegemos al usuario de una experiencia molesta y frustrante, también cuidamos el prestigio de nuestra aplicación en un ecosistema con cientos de opciones / competidores.

Clases de Errores

En Swift contamos con dos grandes categorías de errores: los errores de los que nos podemos recuperar y aquellos donde es inevitable que la aplicación se vea afectada. Algunos ejemplos de errores de los cuales podemos recuperarnos serían:

  • Intentar abrir un archivo que no existe.
  • Intentar comunicarnos con un servidor que está offline.
  • Intentar acceder a un recurso al cual no tenemos permiso.
  • Encontrarnos procesando un flujo de datos que de pronto se interrumpe.

…entre otros.

Con el tiempo nos hemos acostumbrado a que Swift nos obligue a seguir las reglas del lenguaje en tiempo de compilación y de esa manera cuidar esos detalles en nuestro código que pudieran afectar directamente la seguridad de nuestra aplicación. De la misma manera el manejo de errores no es diferente, cuando vayamos a llamar una función que pudiera fallar con un error recuperable, Swift nos obligará a tener en cuenta esa posibilidad.

Por otro lado están los errores de los cuales no podemos recuperarnos y que son algunos tipos especiales:

  • Forzar el desempaquetado de un un tipo opcional que contiene nil.
  • Intentar acceder a un elemento pasado el límite del arreglo.

…entre otros.

En este artículo desarrollaremos parcialmente “un compilador” de dos fases, un ejercicio donde implementaremos una función que evalúe expresiones matemáticas básicas, por ejemplo nosotros pasaremos una cadena de texto como “10 + 5 + 7” y la función retornaría 22. Al mismo tiempo iremos conociendo las facilidades que nos ofrece el lenguaje de programación Swift para lidiar con los errores recuperables tanto como con aquellos que no lo son.

Léxico y Cadenas de Entrada

La primera fase de nuestro compilador será el análisis léxico o lo que sería algo así como un analizador sintáctico. Este tipo de análisis es también conocido como “tokenizing” y no es más que el proceso de tomar una entrada, en este caso una cadena de texto común, sin sentido aparente y convertirla en una secuencia de tokens (símbolos o elementos como pudieran ser un número (1,2,3…) o el signo aritmético de la suma (+)) que indiquen al compilador una acción determinada.

Veamos un ejemplo donde comenzaremos por definir una enumeración:

…con los dos casos que nuestro compilador interpretará. Continuemos ahora con nuestro analizador sintáctico:

Para comenzar el análisis léxico de la cadena de entrada necesitamos acceder a cada caracter de manera individual e ir manteniendo la posición actual dentro de esta colección de caracteres. La propiedad input es de tipo String.CharacterView el cual cuenta con las propiedades startIndex y endIndex que forman puntos guía que representan el inicios de la cadena y el final, permitiéndonos esto recorrer los caracteres de la misma. En el caso de la propiedad position es de tipo String.CharacterView.Index y en esta llevaremos el registro de la posición en la que nos encontramos cuando estemos recorriendo la cadena de caracteres input, por esto (en la segunda línea de init) la hemos inicializado al inicio de la cadena de entrada.

Nuestra clase LexicalAnalyzer necesita dos operaciones básicas en pos de implementar este algoritmo: una manera de poder observar el próximo caracter de la cadena que estamos analizando y una vía de avanzar de la posición actual hacia la siguiente. A su vez la posibilidad de tener en cuenta el siguiente caracter requiere una vía segura de poder indicar que hemos llegado al final de la cadena y para esto retornaremos un valor opcional. Implementemos el método peek():

Este método no recibe ningún parámetro, hacemos uso de guard para verificar que aun no hemos llegado al último caracter de la cadena, de ser así retornamos nil y de lo contrario el caracter correspondiente a la posición actual. El único problema de nuestro código es que aun no hemos actualizado la posición dentro del arreglo de caracteres, es decir que nos encontramos siempre en el mismo ,índice y por ende apuntando al mismo caracter. La solución es bastante simple:

…hemos añadido un nuevo método de nombre advance que lo único que hace es avanzar en un caracter nuestro índice y con esto ya implementamos la movilidad que necesitamos para recorrer todos los caracteres de la cadena. Aquí también tenemos una característica del lenguaje y una novedad de Swift 3, específicamente la siguiente:

A New Model for Collections and Indices

…donde ahora las colecciones mueven sus propios índices, es decir que no podemos hacer algo como:

…en lugar de esto pues hacemos lo que vemos en el ejemplo, nos apoyamos en la propia colección, en este caso en input y en su método formIndex el cual cuenta con dos versiones, la que hemos usado y que es la más segura ya que en su último parámetro nos estamos protegiendo de un error no recuperable al limitar el movimiento del índice a que jamás sobrepase el final del arreglo. La otra sería la siguiente:

…en la cual como vemos solamente contamos con dos parámetros y perdemos el control de seguridad. En este punto y asumiendo que solamente contamos que esta última versión del método formIndex, creo oportuno entrar en materia y añadir un paso de seguridad (externo al método) en pos de prevenir el error antes comentado. Sería de la siguiente forma:

…estamos haciendo uso de la función assert, pero:

¿Qué función cumple assert?

El primer argumento es una condición, donde evaluamos si el índice es menor al correspondiente al último caracter de la colección, mientras esta condición se mantenga como true pues nada sucederá. En la medida que el índice vaya avanzando y eventualmente esta condición se evalúe como false, la ejecución se detendrá en el debugger  y este nos mostrará el mensaje especificado. En este sentido creo importante aclarar que assert solamente es evaluado en modo Debug, si deseamos lograr lo mismo en modo Release y evitar que las llamadas a assert sean eliminadas por el compilador pues tendremos que hacer uso de la función precondition, de la siguiente manera:

…como pueden constatar los parámetros son los mismos que cuando usamos assert.

Continuemos ahora con la implementación del algoritmo léxico recordando que este tiene que retornar un arreglo de tokens. Como parte de esta definición tendremos en cuenta de que algo puede fallar así que haremos uso de la palabra clave throws luego de los paréntesis de los argumentos:

Aquí tenemos al método lex el cual se apoya en el método peek para obtener un caracter. Luego mediante una sentencia switch procedemos a determinar si este caracter es un número (0…9), el símbolo de suma (+) o un espacio en blanco(” “) y en caso de que sea algo más que estas tres opciones pues emitimos un error (línea 27).

Para poder continuar el análisis del método lex necesitamos develar el método getNumber al igual que la enumeración TokenError, veamos el primero:

Vista la implementación del método getNumber seguimos por donde nos quedamos en el método lex, en el primer caso de la sentencia switch. Como todos sabemos los números pueden tener varios dígitos así que teniendo esto en cuenta es lógico que no podemos almacenar un caracter numérico así como así ya que el siguiente caracter puede ser su segundo dígito. A modo de ejemplo asumamos que el número en cuestión es 25, de este el método lex ya detectó el primer dígito (2) así que lo primero que hace es llamar a getNumber para que verifique si existen más dígitos y de ser así nos devuelva el número correctamente formado, es decir que en lugar de cada dígito por separado (2 y 5) nos devuelva el entero completo, en este caso 25.

Para lograr esto nos apoyamos nuevamente en peek para tomar el número 2 al que luego en la línea 11 convertimos a entero y lo almacenamos en la constante digitValue. En la línea siguiente igualamos value al producto de la expresión matemática:

10 * value + digitValue = 10 * 0 + 2  = 2    =>    value = 2

…nuestro código continúa avanzando la posición del índice en un caracter y con esto volvemos a otro ciclo del bucle while. Se repite todo el proceso, se encuentra el caracter numérico 5, se convierte a entero, se almacena en digitValue y se ejecuta el cálculo:

10 * value + digitValue = 10 * 2 + 5  = 25    =>    value = 25

…esta vez ya quedando el número 25 en una sola unidad. Como el siguiente caracter es un espacio vacío se ejecuta el caso por defecto el cual retorna el número almacenado en value y así finaliza la ejecución del método getNumber. En este punto retornamos a la línea 11 del método lex almacenando el valor en la constante value para acabar en la línea 13 por añadirlo al arreglo de Tokens que hemos creado al inicio.

Terminado este caso pues vendría el siguiente relacionado al símbolo de suma el cual sería almacenado como un token más y de último tenemos los espacios en blanco que sencillamente son ignorados pasando al siguiente caracter, hasta que llegamos al caso por defecto que es donde emitimos un error al encontrarnos con un caracter inesperado y para esto nos apoyamos en:

…la enumeración TokenError que adopta el protocolo Error y solamente define un caso de nombre InvalidCharacter que a su vez recibe un caracter como parámetro, el caracter que produciría el error de no haberse detectado.

Ya nuestra clase LexicalAnalyzer se encuentra terminada así que ahora crearemos una función que nos ayude a probarla:

…pero la función evaluate aunque debería funcionar tiene un error. El compilador Swift nos muestra el siguiente mensaje:

error: call can throw, but it is not marked with ‘try’ and the error is not handled

…básicamente lo que esto quiere decir es que el método lex puede emitir un error y sin embargo esta llamada no está marcada con “try” y el error tampoco está siendo manejado, es decir que no estamos tomando medidas para el caso donde el error pueda ocurrir.

Atrapando Errores

Como podemos constatar no es una advertencia, el lenguaje de programación Swift siempre pone la seguridad por delante de todo, así que es un mensaje de error con todas las de la ley y que nos obliga a corregirlo para poder continuar. Hagámoslo. La nueva versión sería:

El manejo de errores en Swift se logra con un bloque de control do / catch con al menos una sentencia try dentro del segmento do. El bloque do introduce un nuevo ámbito donde podemos escribir código de manera normal, pero que está especialmente diseñado para introducir las llamadas a funciones o métodos que han sido marcados como throws y a los cuales también hay que señalizar con la palabra clave try. Al final del segmento do declaramos el bloque catch que es el encargado de manejar los errores cuando estos se producen, es decir que el flujo del programa se redirige hacia los múltiples bloques catch que podemos tener y que funcionan de manera similar a los casos en la sentencia switch donde se va evaluando cada uno y aquel error que coincida con el emitido, pues será el bloque catch que se ejecutará, en este caso el comprendido entre las líneas 13 y la 17.

Antes de proseguir probemos este código con las siguientes líneas:

…la salida en pantalla sería:

Haciendo Parsing sobre el Arreglo de Tokens

Ahora necesitamos implementar un algoritmo en pos de hacer parse sobre el arreglo de tokens. El orden en el que los tokens se encuentran es bien importante ya que esto puede volver una expresión válida en otra completamente sinsentido. La reglas serían:

  • El primer token tiene que ser un número.
  • Al hacer parsing sobre un número o bien este corresponde al último token de la entrada o el próximo token tiene que ser el símbolo de suma.
  • Luego de hacer parsig sobre el símbolo de suma el próximo token tiene que ser un número.

La implementación del algoritmo de nuestro Parser si bien es más restrictivo dado las reglas antes comentadas, es también más sencillo ya que no necesitamos de los métodos peek() o advance(). En este caso ambos pueden ser combinados en un método getNextToken() que retorne el próximo token o nil en caso de haber alcanzado el final.

Aquí tenemos un fragmento (vayamos por parte, poco a poco) de la clase Parser y lo primero que vemos no es nuevo, hemos declaramos una enumeración con errores definidos por nosotros y que sabemos de antemano que nuestro código los podría generar. Luego declaramos un arreglo constante de tipo Token que almacenará los tokens que vamos a procesar y que es inicializado mediante el constructor de la clase, acto seguido tenemos una variable en representación del índice dentro de este arreglo. Por último en el método getNextToken lo primero que hacemos es verificar que nuestro índice es menor que el número máximo de elementos en el arreglo y en el caso que esto ya no sea cierto pues el método retornará nil y su ejecución se verá terminada. El próximo bloque defer tiene un comportamiento curioso, este se ejecuta luego de la sentencia return, es decir que la última fase de ejecución del método getNextToken es el bloque defer. En este aumentamos la variable position en una unidad y el objetivo de hacer esto de esta manera responde a un cambio que ha sufrido el lenguaje Swift en su versión 3, específicamente la siguiente:

Remove the ++ and -- operators

…la cual impide que hagamos algo como lo siguiente:

Asumiendo que esta línea fuera válida estaríamos retornando el primer Token correspondiente al índice 0 y luego de esto, y producto del operador ++, la variable position sería aumentada en uno, así que como esto ya no es posible, pero necesitamos lograr el mismo comportamiento, el bloque defer constituye la mejor opción a elegir.

Continuemos desarrollando nuestra clase Parser, ahora añadiendo el método getNumber:

En este método hemos usado la palabra clave throws con la cual informamos al compilador que pudiéramos lanzar un error. Dentro del bloque comenzamos de la misma manera que ya habíamos visto en el método anterior haciendo uso de guard, espacio donde ejecutamos el método getNextToken y en caso de que este retorne nil lanzamos el error:

ErrorToken.UnexpectedEndOfInput

…el cual nos informa de que el último Token no es correcto, no es el esperado bajo las reglas que hemos preestablecido. Al final tenemos un bloque switch donde determinamos si el Token es un valor numérico o un símbolo de suma, en caso de ser este último lanzamos el error:

 ErrorToken.InvalidToken(token)

…ya que este método se ejecuta en pos de obtener un valor numérico (de ahí su nombre) y si encontramos un símbolo de suma en esta posición es porque el formato de la operación matemática no es el correcto.

Terminamos la definición de la clase Parser con el método parse:

Como podemos observar este método también ha sido marcado con throws por la razón antes explicada. La primera línea que ejecutamos es donde hacemos la llamada al método antes visto, aquí tenemos que tener en cuenta que en la primera llamada debemos de poder obtener un valor numérico sin problemas tomando como ejemplo la cadena:

10 + 5 + 5

…luego tenemos un ciclo while donde efectuamos una llamada al método getNextToken de donde obtendremos el símbolo de suma y en caso contrario emitimos el error correspondiente. Para probar todos los cambios que hemos efectuado actualicemos la función evaluate a la siguiente versión:

…luego ejecutaríamos:

…la salida en pantalla sería:

En el caso de:

…la salida sería:

Para generar el segundo error tendríamos que hacer lo siguiente:

…la salida:

Como podemos constatar, nuestro código ya es capaz de completar la tarea, lo hace de manera correcta al mismo tiempo que reacciona a los errores que hemos definido y que violan las reglas establecidas para el buen funcionamiento de nuestro programa.

La versión final del código que hemos venido desarrollando hasta ahora sería el siguiente:

Como ya hemos comentado Swift es un lenguaje de programación moderno, diseñado con un enfoque, bien claro, a incentivar tanto la legibilidad en el código como la seguridad y donde el manejo de errores no es menos. Cualquier función o método que pueda fallar tiene que ser marcado con throws así como todas las llamadas a estas tienen que estar marcadas con try. Es evidente el beneficio a nivel de legibilidad ya que donde veamos estas dos palabras claves sabremos que estamos ante un código que pudiera convertirse en una fuente de errores.  Por último creo importante explicar otra característica bien importante que ha sido aplicada al manejo de errores en el lenguaje Swift. Me refiero a que una función throws no trae consigo de manera implícita que tipo de error será emitido en caso de ocurrir alguno y esto tiene dos impactos prácticos:

  • El primero sería que somos siempre libres de añadir más tipos de errores potenciales sin necesidad de cambiar la API de esta función.
  • El segundo haría referencia al manejo de errores con catch donde siempre tenemos que estar preparados para encontrarnos ante alguno no especificado.

Sobre este segundo punto el compilador de Swift hace mucho hincapié en que lo tengamos en cuenta, en otras palabras, nos obliga a manejar esta posibilidad, por esto es que en el ejemplo anterior, en la versión final y específicamente de la línea 231 a la 235 lo exponemos de forma práctica.

Ya al final creo que solamente nos quedaría profundizar sobre el papel de sentencia try en el manejo de errores. Esta información la podremos encontrar en el artículo que hemos dedicado a la palabra clave try y sus dos variantes.

Espero que todo cuanto se ha dicho aquí, de una forma u otra le haya servido de aprendizaje, de referencia, que haya valido su preciado tiempo.

Este artículo, al igual que el resto, será revisado con cierta frecuencia en pos de mantener un contenido de calidad y actualizado.

Cualquier sugerencia, ya sea errores a corregir, información o ejemplos a añadir será, más que bienvenida, necesaria!