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:
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:
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.
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:
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.