[C#] ¿Cómo funcionan las excepciones?

Introducción

Una excepción no suele ser algo bueno en nuestro sistema. Puede ser porque haya sido provocada para controlar o prevenir un comportamiento incorrecto o directamente puede ser que algo se nos haya pasado y falle todo. El objetivo de este artículo es profundizar un poco en qué ocurre cada vez que se lanza una excepción. Así que vayamos al grano y fijémonos en el siguiente fragmento de código:

   1: static void Main(string[] args)

   2: {

   3:     var list = new List<int>();

   4:     var result = list[1];

   5: }

Evidentemente, producirá una excepción de tipo ArgumentOutOfRangeException indicando que no ha sido controlada y el programa se cerrará. ¿Qué ha ocurrido aquí? Según el detalle de la excepción, tenemos:

clip_image002

Analizando el StackTrace podemos ver:

Si nos fijamos, vemos toda la traza del hilo y procesos asoaciados al Framework hasta que llegamos a System.GenericList que es donde se produce la excepción. En concreto, dentro del get accessor:

clip_image004

Bien, es obvio. GenericList verifica que el elemento que estamos accediendo es superior a la cantidad de elementos que tiene la lista y lanza la excepción de tipo ArgumentOutOfRangeException. Todo lo que vemos debajo en la pila es en primer lugar la clase que está llamando a la lista y así sucesivamente hasta llegar al hilo que ha arrancado todo el proceso. Puesto que la excepción no está controlada, el Framework detendrá el hilo de ejecución actual:

   1: System.Threading.Thread.CurrentThread.Abort();

Al detener el hilo, se lanza la excepción ThreadAbortExcepction y se detiene toda la ejecución del proceso.

Capturar la excepción

Una vez que hemos visto qué ocurre cuando tenemos una excepción que no está provocada, vamos a ver qué es lo que ocurre cuando intentamos capturarla. Siguiendo el ejemplo anterior, podemos introducir una cláusula try/catch para ver qué ocurre. El comportamiento esperado es que el programa se ejecutará, se producirá la excepción y ésta será capturada por el bloque catch. El flujo del programa no se detendrá y el hilo finalizará correctamente:

   1: static void Main(string[] args)

   2: {

   3:     var list = new List<int>();

   4:     try

   5:     {

   6:         var result = list[1];

   7:     }

   8:     catch (Exception ex)

   9:     {

  10:         // Do something...

  11:     }

  12: }

Hemos puesto por ahora una excepción genérica a capturar. Veamos qué código CIL ha generado esto:

   1: .method private hidebysig static void  Main(string[] args) cil managed

   2: {

   3:   .entrypoint

   4:   // Code size       26 (0x1a)

   5:   .maxstack  2

   6:   .locals init ([0] class [mscorlib]System.Collections.Generic.List`1<;int32> list,

   7:            [1] int32 result,

   8:            [2] class [mscorlib]System.Exception ex)

   9:   IL_0000:  nop

  10:   IL_0001:  newobj     instance void class [mscorlib]System.Collections.Generic.List`1<;int32>::.ctor()

  11:   IL_0006:  stloc.0

  12:   .try

  13:   {

  14:     IL_0007:  nop

  15:     IL_0008:  ldloc.0

  16:     IL_0009:  ldc.i4.1

  17:     IL_000a:  callvirt   instance !0 class [mscorlib]System.Collections.Generic.List`1<;int32>::get_Item(int32)

  18:     IL_000f:  stloc.1

  19:     IL_0010:  nop

  20:     IL_0011:  leave.s    IL_0018

  21:   }  // end .try

  22:   catch [mscorlib]System.Exception 

  23:   {

  24:     IL_0013:  stloc.2

  25:     IL_0014:  nop

  26:     IL_0015:  nop

  27:     IL_0016:  leave.s    IL_0018

  28:   }  // end handler

  29:   IL_0018:  nop

  30:   IL_0019:  ret

  31: } // end of method Program::Main

Nótese un par de puntos interesantes. Vemos que como valores locales establecemos la List<int> que queremos acceder, la variable result (que el compilador ha especificado como tipo int32) y por último un tipo System.Exception. Vemos claramente el try y el catch definidos.

Al finalizar el try y el catch aparece la instrucción leave.s. Esta instrucción adquiere sentido en el contexto actual, puesto que dentro de CLR los bloques try/catch.. se consideran bloques protegidos. Al ejecutar la instrucción leave.s (o leave a secas indicando la dirección) indicamos que vamos a salir del bloque protegido, cedemos la gestión a CLR y además vamos a una determinada dirección. En este caso y en los siguientes, leave.s hace referencia a una dirección que suele ser la finalización de la sección try para que el flujo continúe por la función.

Volviendo al tema que nos ocupa, el código de C# es idéntico al del primer caso expuesto. Se ejecuta una función que provoca una excepción. En ese momento, el Framework busca primero dentro de la región actual si hay alguna instrucción catch que sea del mismo tipo que la excepción que se ha producido. En ese caso, salta al bloque catch correspondiente y sigue su curso. Y, ¿qué ocurre si no encuentra un bloque catch? Pues en ese momento es cuando el Framework recorre TODA la pila de llamadas buscando un bloque que lo capture. Y cuando no lo encuentra, él mismo detiene el hilo lanzando el ThreadAbortException.

image

Por ejemplo, vamos a incluir que capture el ArgumentOutOfRangeException y veremos el código CIL que genera. En primer lugar tenemos el código:

   1: static void Main(string[] args)

   2: {

   3:     var list = new List<int>();

   4:     try

   5:     {

   6:         var result = list[1];

   7:     }

   8:     catch (ArgumentOutOfRangeException ex)

   9:     {

  10:         // 

  11:     }

  12:     catch (Exception ex)

  13:     {

  14:         // Do something...

  15:     }

  16: }

Y aquí el código CIL:

   1: .method private hidebysig static void  Main(string[] args) cil managed

   2: {

   3:   .entrypoint

   4:   // Code size       31 (0x1f)

   5:   .maxstack  2

   6:   .locals init ([0] class [mscorlib]System.Collections.Generic.List`1<;int32> list,

   7:            [1] int32 result,

   8:            [2] class [mscorlib]System.ArgumentOutOfRangeException ex,

   9:            [3] class [mscorlib]System.Exception V_3)

  10:   IL_0000:  nop

  11:   IL_0001:  newobj     instance void class [mscorlib]System.Collections.Generic.List`1<;int32>::.ctor()

  12:   IL_0006:  stloc.0

  13:   .try

  14:   {

  15:     IL_0007:  nop

  16:     IL_0008:  ldloc.0

  17:     IL_0009:  ldc.i4.1

  18:     IL_000a:  callvirt   instance !0 class [mscorlib]System.Collections.Generic.List`1<;int32>::get_Item(int32)

  19:     IL_000f:  stloc.1

  20:     IL_0010:  nop

  21:     IL_0011:  leave.s    IL_001d

  22:   }  // end .try

  23:   catch [mscorlib]System.ArgumentOutOfRangeException 

  24:   {

  25:     IL_0013:  stloc.2

  26:     IL_0014:  nop

  27:     IL_0015:  nop

  28:     IL_0016:  leave.s    IL_001d

  29:   }  // end handler

  30:   catch [mscorlib]System.Exception 

  31:   {

  32:     IL_0018:  stloc.3

  33:     IL_0019:  nop

  34:     IL_001a:  nop

  35:     IL_001b:  leave.s    IL_001d

  36:   }  // end handler

  37:   IL_001d:  nop

  38:   IL_001e:  ret

  39: } // end of method Program::Main

Como se puede apreciar, disponemos de las dos regiones de cada uno de los catch. Una para el ArgumentOutOfRangeException y otra para la System.Exception básica. Si recordamos la pila de llamadas expuesta al inicio del artículo, el proceso seguirá siendo tal que:

image

Cuyo algoritmo es:

– List<T> lanzo una excepción, esperando que el elemento anterior en la pila de llamadas la captura.

– Elemento anterior de la pila: Recibo una excepción. ¿Estoy dentro de un try/catch? ¿Tengo algún catch con el tipo de excepción recibida? Si es así, la capturo y ejecuto el bloque catch correspondiente. Si no, entonces  busco en el elemento anterior de la pila. Y así sucesivamente hasta llegar al fondo de la pila.

Cuando llega al fondo de la pila y la excepción no se ha controlado, el propio Framework mata el proceso con la instrucción previamente mencionada en la introducción.

Lanzando una excepción

Bien, ahora que sabemos qué ocurre cuando la capturamos, veamos qué ocurre cuando la lanzamos. Siguiendo con el patrón de código usado hasta ahora, vamos a lanzar la excepción que recibimos de List<int>:

   1: static void Main(string[] args)

   2: {

   3:    var list = new List<int>();

   4:    try

   5:    {

   6:        var result = list[1];

   7:    }

   8:    catch (ArgumentOutOfRangeException ex)

   9:    {

  10:        throw ex;

  11:    }

  12:    catch (Exception ex)

  13:    {

  14:        // Do something...

  15:    }

  16: }

Colocamos que en un catch, nos lance la excepción. Nótese el término throw, que significa que lanzamos hacia arriba la excepción, en el sentido que el elemento que esté en la parte anterior de la pila del actual será quien reciba el objeto. Veamos el CIL:

   1: .method private hidebysig static void  Main(string[] args) cil managed

   2: {

   3:   .entrypoint

   4:   // Code size       30 (0x1e)

   5:   .maxstack  2

   6:   .locals init ([0] class [mscorlib]System.Collections.Generic.List`1<;int32> list,

   7:            [1] int32 result,

   8:            [2] class [mscorlib]System.ArgumentOutOfRangeException ex,

   9:            [3] class [mscorlib]System.Exception V_3)

  10:   IL_0000:  nop

  11:   IL_0001:  newobj     instance void class [mscorlib]System.Collections.Generic.List`1<;int32>::.ctor()

  12:   IL_0006:  stloc.0

  13:   .try

  14:   {

  15:     IL_0007:  nop

  16:     IL_0008:  ldloc.0

  17:     IL_0009:  ldc.i4.1

  18:     IL_000a:  callvirt   instance !0 class [mscorlib]System.Collections.Generic.List`1<;int32>::get_Item(int32)

  19:     IL_000f:  stloc.1

  20:     IL_0010:  nop

  21:     IL_0011:  leave.s    IL_001c

  22:   }  // end .try

  23:   catch [mscorlib]System.ArgumentOutOfRangeException 

  24:   {

  25:     IL_0013:  stloc.2

  26:     IL_0014:  nop

  27:     IL_0015:  ldloc.2

  28:     IL_0016:  throw

  29:   }  // end handler

  30:   catch [mscorlib]System.Exception 

  31:   {

  32:     IL_0017:  stloc.3

  33:     IL_0018:  nop

  34:     IL_0019:  nop

  35:     IL_001a:  leave.s    IL_001c

  36:   }  // end handler

  37:   IL_001c:  nop

  38:   IL_001d:  ret

  39: } // end of method Program::Main

Y analizamos el fragmento en cuestión:

   1: catch [mscorlib]System.ArgumentOutOfRangeException 

   2: {

   3:   IL_0013:  stloc.2

   4:   IL_0014:  nop

   5:   IL_0015:  ldloc.2

   6:   IL_0016:  throw

   7: }  // end handler

Únicamente lo que hacemos aquí es almacenar el valor que está en la cima de la pila, que es el recibido por la excepción y de tipo ArgumentOutOfRangeException (variable que dentro de las variables locales tiene la posición 2, como se puede apreciar en el código completo). La volvemos a cargar para que esté en la cima e invocamos a la instrucción throw para que ese objeto lo reciba el elemento que está en la parte superior de la pila del actual.

Finally

Ahora veamos qué comportamiento introduce una cláusula finally en todo esto. Añadiremos el finally al código actual de modo que tendremos:

   1: static void Main(string[] args)

   2: {

   3:     var list = new List<int>();

   4:     try

   5:     {

   6:         var result = list[1];

   7:     }

   8:     catch (ArgumentOutOfRangeException ex)

   9:     {

  10:         throw ex;

  11:     }

  12:     catch (Exception ex)

  13:     {

  14:         // Do something

  15:     }

  16:     finally

  17:     {

  18:         // Finally clausule

  19:     }

  20: }

Y veamos el código CIL generado:

   1: .method private hidebysig static void  Main(string[] args) cil managed

   2: {

   3:   .entrypoint

   4:   // Code size       36 (0x24)

   5:   .maxstack  2

   6:   .locals init ([0] class [mscorlib]System.Collections.Generic.List`1<;int32> list,

   7:            [1] int32 result,

   8:            [2] class [mscorlib]System.ArgumentOutOfRangeException ex,

   9:            [3] class [mscorlib]System.Exception V_3)

  10:   IL_0000:  nop

  11:   IL_0001:  newobj     instance void class [mscorlib]System.Collections.Generic.List`1<;int32>::.ctor()

  12:   IL_0006:  stloc.0

  13:   .try

  14:   {

  15:     .try

  16:     {

  17:       IL_0007:  nop

  18:       IL_0008:  ldloc.0

  19:       IL_0009:  ldc.i4.1

  20:       IL_000a:  callvirt   instance !0 class [mscorlib]System.Collections.Generic.List`1<;int32>::get_Item(int32)

  21:       IL_000f:  stloc.1

  22:       IL_0010:  nop

  23:       IL_0011:  leave.s    IL_001c

  24:     }  // end .try

  25:     catch [mscorlib]System.ArgumentOutOfRangeException 

  26:     {

  27:       IL_0013:  stloc.2

  28:       IL_0014:  nop

  29:       IL_0015:  ldloc.2

  30:       IL_0016:  throw

  31:     }  // end handler

  32:     catch [mscorlib]System.Exception 

  33:     {

  34:       IL_0017:  stloc.3

  35:       IL_0018:  nop

  36:       IL_0019:  nop

  37:       IL_001a:  leave.s    IL_001c

  38:     }  // end handler

  39:     IL_001c:  nop

  40:     IL_001d:  leave.s    IL_0022

  41:   }  // end .try

  42:   finally

  43:   {

  44:     IL_001f:  nop

  45:     IL_0020:  nop

  46:     IL_0021:  endfinally

  47:   }  // end handler

  48:   IL_0022:  nop

  49:   IL_0023:  ret

  50: } // end of method Program::Main

  51:  

Centrémonos en dos secciones. Por una parte vemos que el flujo ha cambiado de forma significativa. Primero notamos la introducción de la instrucción finally dentro del código. Vemos además que todos tanto el try/catch cuando finalizan, saltan al final de la función y no al finally. Esto es porque el CLR ejecuta siempre el bloque finally en cualquier condición, por lo que él asocia los caminos de salida de los respectivos bloques pero siempre al terminar el try/catch, salta a la sección finally. La sección de finally también es un bloque de código protegido, al igual que el try/catch:

   1: finally

   2: {

   3:     IL_0020:  nop

   4:     IL_0021:  nop

   5:     IL_0022:  endfinally

   6: }  // end handler

La única particularidad del bloque finally es que tiene que finalizar con la instrucción endfinally. De todas formas, la única función de un bloque finally es la de hacer de “cleanup”, es decir, de liberar todo recurso empleado en el try.

Conclusiones

Hemos visto cómo funciona una excepción por dentro para entender su comportamiento y su mecanismo. Siempre se recomienda que no se deben usar excepciones como mecanismo de control de flujo, por mala praxis en el desarrollo y por asuntos de rendimiento:

  • Cada vez que se produce una excepción, CLR va a buscar en toda la pila de llamadas el bloque catch que coincida con el tipo de la excepción producida. Esto, evidentemente genera un problema de rendimiento importante si sucede muy a menudo.
  • No hay que abusar de excepciones. Las justas y necesarias para tenerlo todo bajo control. El valor ideal como siempre, en el término medio. Ni muchas ni ninguna, las necesarias.
  • Si capturamos una excepción,  hagamos algo con ella. La podemos lanzar, tracear, modificarla para lanzar otra distinta… pero siempre debemos hacer algo con ella. Nunca dejar un catch vacío.
  • Por supuesto, el catch debe ser seguro. No debe tener hueco que se produzca una excepción nueva dentro de un bloque catch.
  • Por último, el bloque finally debe actuar como cleanup. Debe liberar memoria/recursos/IO que hayamos inicializado en el try. Ten en cuenta que siempre se va a ejecutar.