Noviembre de 2015

Volumen 30, número 12

Essential .NET: control de excepciones de C#

Mark Michaelis | Noviembre de 2015

Mark MichaelisBienvenido a la columna inaugural de Essential .NET. Aquí es dónde podrá seguir todo lo que está pasando en el mundo de Microsoft .NET Framework: si hay avances en C# vNext (actualmente, C# 7.0), mejoras en los elementos internos de .NET o sucesos en la parte frontal de Roslyn y .NET Core (p. ex. si MSBuild pasa a tener código abierto).

He estado escribiendo y desarrollando con .NET desde que se anunció en la vista previa de 2000. En muchos de los temas que escribiré no solo presentaré novedades, sino que describiré cómo aprovechar la tecnología sin perder de vista los procedimientos recomendados.

Vivo en Spokane, Wash. y soy el "jefe nerd" de una empresa de consultoría de gama alta llamada IntelliTect (IntelliTect.com). IntelliTect se especializa en el desarrollo de "cosas complejas" con excelencia. Soy MVP de Microsoft (actualmente de C#) desde hace 20 años y he sido director regional de Microsoft durante ocho de estos años. Hoy, doy inicio a esta columna haciendo un vistazo en las instrucciones de control de excepciones actualizadas.

C# 6.0 incluía dos nuevas funciones para el control de excepciones. De un lado, incluía compatibilidad con condiciones de excepciones, es decir, la capacidad de proporcionar una expresión que filtra una excepción de la entrada de bloque catch antes de que se desenrede la pila. De otro lado, incluía compatibilidad asincrónica desde dentro de un bloque catch, una opción que no era posible en C# 5.0 cuando se agregó la asincronía al lenguaje. Además, se han realizado muchos otros cambios en las últimas cinco versiones de C# y .NET Framework correspondiente. Estos cambios, en algunos casos, son lo suficientemente significativos como para tener que editar las instrucciones de codificación de C#. En esta publicación, revisaré muchos de estos cambios y proporcionaré instrucciones de codificación actualizadas, dado que se relacionan con el control de excepciones y la detección de excepciones.

Detectar excepciones: Revisión

Como es bien sabido, la generación de un tipo de excepción concreto permite al detector usar el tipo de excepción en sí para identificar el problema. En otras palabras, no es necesario detectar la excepción y usar una instrucción de modificador en el mensaje de excepción para determinar la acción que se debe tener en cuenta de la excepción. En su lugar, C# admite varios bloques catch, cada uno de los cuales se destina a un tipo de excepción específica, tal como se muestra en la Ilustración 1.

Ilustración 1 Detectar tipos de excepciones diferentes

using System;
public sealed class Program
{
  public static void Main(string[] args)
    try
    {
       // ...
      throw new InvalidOperationException(
         "Arbitrary exception");
       // ...
   }
   catch(System.Web.HttpException exception)
     when(exception.GetHttpCode() == 400)
   {
     // Handle System.Web.HttpException where
     // exception.GetHttpCode() is 400.
   }
   catch (InvalidOperationException exception)
   {
     bool exceptionHandled=false;
     // Handle InvalidOperationException
     // ...
     if(!exceptionHandled)
       // In C# 6.0, replace this with an exception condition
     {
        throw;
     }
    }  
   finally
   {
     // Handle any cleanup code here as it runs
     // regardless of whether there is an exception
   }
 }
}

Cuando se produzca una excepción, la ejecución saltará al primer bloque catch que pueda controlarla. Si hay más de un bloque catch asociado con el intento, la cadena de herencia determina la similitud de una coincidencia (suponiendo que no haya ninguna condición de excepción de C# 6.0) y el primero que coincida procesará la excepción. Por ejemplo, aunque la excepción generada es del tipo System.Exception, esta relación de tipo "es un" se produce a través de una herencia porque System.Invalid­OperationException deriva, en última instancia, de System.Exception. Dado que la excepción InvalidOperationException es la que más coincide con la excepción generada, el bloque catch(InvalidOperationException...) detectará la excepción, pero no detectará el bloque catch(Exception...) si hay uno.

Los bloques catch deben aparecer ordenados (suponiendo de nuevo que no haya ninguna condición de excepción de C# 6.0), del más específico al más general, con el fin de evitar un error en tiempo de compilación. Por ejemplo, si agrega un bloque catch(Exception...) antes de cualquier otra excepción, se producirá un error de compilación porque todas las excepciones anteriores derivan de System.Exception en algún punto de su cadena de herencia. También, tenga en cuenta que no se necesita un parámetro con nombre para el bloque catch. De hecho, se permite un bloque catch final sin el tipo de parámetro, lamentablemente, según se trata en el apartado bloque catch general.

En ocasiones, después de detectar una excepción, es posible que determine que, realmente, no se puede controlar la excepción de manera adecuada. En este caso, tiene dos opciones principales. La primera consiste en volver a generar otra excepción. Existen tres escenarios comunes en los que deba realizar esta acción:

Escenario n.º1 La excepción detectada no identifica de forma suficiente el problema que la desencadenó. Por ejemplo, al llamar a System.Net.WebClient.DownloadString con una URL válida, puede que el tiempo de ejecución genere System.Net.WebException cuando no haya ninguna conexión de red, la misma excepción que se genera con una URL no existente.

Escenario n.º2 La excepción detectada incluye datos privados que no se deben exponer en un nivel superior de la cadena de llamada. Por ejemplo, una versión muy preliminar de CLR v1 (incluso, una versión previa a la alfa) tenía una excepción que indicaba un mensaje tipo: "Excepción de seguridad: No tiene permiso para determinar la ruta de acceso de c:\temp\foo.txt."

Escenario n.º3 La excepción es demasiado específica para que la controle el llamador. Por ejemplo, al invocar un servicio web para buscar un código postal, se produce una excepción System.IO (como Unauthorized­AccessException IOException FileNotFoundException DirectoryNotFoundException PathTooLongException, NotSupportedException o SecurityException ArgumentException) en el servidor.

Cuando se vuelva a generar otra excepción, preste atención al hecho de que se podría perder la excepción original (probablemente de forma intencional en el caso del escenario 2). Para evitar esto, establezca la propiedad InnerException de la excepción encapsulada, que en general se puede asignar a través del constructor, con la excepción detectada, a menos que al hacer eso se expongan los datos privados que no se deben exponer en un nivel superior de la cadena de llamada. Al hacer eso, el seguimiento de la pila original aún estará disponible.

Si no establece la excepción interna, pero especifica la instancia de excepción después de la instrucción throw (excepción throw), el seguimiento de la pila de ubicación se establecerá en la instancia de excepción. Aunque vuelva a generar la excepción previamente detectada, cuyo seguimiento de la pila ya se estableció, se volverá a establecer.

Otra opción al detectar una excepción es determinar que, de hecho, no puede controlarla de forma adecuada. En este caso, le recomendamos que vuelva a generar la misma excepción, enviándola al siguiente controlador de la cadena de llamada. En el bloque catch InvalidOperationException de la Ilustración 1 se muestra esto. Aparece una instrucción throw sin ninguna identificación de la excepción para generarse (throw está solo), aunque aparece una instancia de excepción (excepción) en el ámbito de bloque catch que se podría volver a generar. Si se genera una excepción específica, se actualizaría toda la información de la pila para hacerla coincidir con la nueva ubicación de la excepción throw. Como resultado, se perdería toda la información de la pila que indica el sitio de la llamada en la que se produjo la excepción originalmente, lo que dificultaría considerablemente la posibilidad de diagnosticar el problema. Una vez se determine que un bloque catch no puede controlar de forma suficiente una excepción, se debería volver a generar una excepción con una instrucción throw vacía.

Independientemente de si genera la misma excepción o encapsula una excepción, las instrucciones generales indican que debe evitar los informes de excepciones o el registro en un nivel inferior de la pila de llamadas. En otras palabras, no registre una excepción cada vez que la detecte y la vuelva a generar. Si lo hace, se produce un desorden innecesario en los archivos de registro sin agregar valor, porque cada vez se registrará lo mismo. Además, la excepción incluye datos de seguimiento de la pila de cuando se genera, por lo que no necesita registrarla cada vez. De todas maneras, registre la excepción siempre que esta se controle, o bien, en caso de que no se vaya a controlar, regístrela antes de cerrar un proceso.

Generar excepciones existentes sin reemplazar la información de la pila

En C# 5.0, se agregó un mecanismo que permite generar una excepción generada anteriormente sin perder la información de seguimiento de la pila en la excepción original. De esta manera, puede volver a generar excepciones, por ejemplo, incluso desde fuera de un bloque catch y, por lo tanto, sin usar una excepción throw vacía. Aunque en general no es necesario tener que hacer esto, en algunas ocasiones las excepciones se encapsulan o se guardan hasta que la ejecución del programa se mueve a fuera del bloque catch. Por ejemplo, es posible que el código multiproceso encapsule una excepción con AggregateException. .NET Framework 4.5 proporciona una clase System.Runtime.ExceptionServices.ExceptionDispatchInfo específicamente para controlar este escenario mediante el uso de sus métodos estáticos Throw de la instancia y Capture. En la Ilustración 2 se muestra el reinicio de la excepción sin restablecer la información de seguimiento de la pila ni usar una instrucción throw vacía.

Ilustración 2 Usar ExceptionDispatchInfo para volver a generar una excepción

using System
using System.Runtime.ExceptionServices;
using System.Threading.Tasks;
Task task = WriteWebRequestSizeAsync(url);
try
{
  while (!task.Wait(100))
{
    Console.Write(".");
  }
}
catch(AggregateException exception)
{
  exception = exception.Flatten();
  ExceptionDispatchInfo.Capture(
    exception.InnerException).Throw();
}

Mediante el método ExeptionDispatchInfo.Throw, el compilador no la trata como una instrucción return al igual que lo haría una instrucción throw normal. Por ejemplo, si la firma del método devolviera un valor, pero la ruta de acceso de código no devolviera ningún valor con ExceptionDispatchInfo.Throw, el compilador emitiría un error en el que se indicaría que no se devolvió ningún valor. En ocasiones, puede que los desarrolladores se vean obligados a seguir el método ExceptionDispatchInfo.Throw con una instrucción return, aunque dicha instrucción nunca se ejecutaría en tiempo de ejecución; se generaría una excepción en su lugar.

Detectar excepciones en C# 6.0

En las instrucciones de control de excepciones generales se indica que se debe evitar la detección de excepciones que no puede abordar en su totalidad. Sin embargo, dado que las expresiones catch anteriores a C# 6.0 solo podían filtrar por tipo de excepción, la capacidad de comprobar los datos de la excepción y el contexto anterior al desenredo de pila en el bloque catch requerían que el bloque catch se convirtiera en el controlador de la excepción antes de examinarla. Desafortunadamente, una vez se determina que no se controla la excepción, es complicado escribir código que permita un bloque catch distinto dentro del mismo contexto para controlar la excepción. Además, si se vuelve a generar la misma excepción, se deberá invocar de nuevo el proceso de excepción de dos pasos, un proceso que implica, en primer lugar, la entrega de la excepción en un nivel superior de la cadena de llamada hasta que encuentra una que la controla y, en segundo lugar, el desenredo de la pila de llamadas para cada marco entre la excepción y la ubicación catch.

Una vez que se genera una excepción, en lugar de desenredar la pila de llamadas solo en el bloque catch para que la excepción se vuelva a generar, porque un examen adicional de la excepción reveló que no se podría controlar de manera suficiente, evidentemente es preferible que la excepción no se detecte en primer lugar. Comenzando con C# 6.0, hay una expresión condicional adicional disponible para los bloques catch. En lugar de limitar si un bloque catch coincide solo según una coincidencia de tipo de excepción, C# 6.0 incluye compatibilidad con una cláusula condicional. La cláusula WHEN le permite suministrar una expresión booleana que aplique filtros adicionales en el bloque catch para que solo controle la excepción si la condición es verdadera. En el bloque System.Web.HttpException de la Ilustración 1 se mostraba esto con un operador de comparación de igualdad.

Un resultado interesante de la condición de excepción es que, cuando se proporciona una condición de excepción, el compilador no fuerza los bloques catch para que aparezcan en el orden de la cadena de herencia. Por ejemplo, un bloque catch del tipo System.ArgumentException con una condición de excepción que lo acompaña ahora puede aparecer antes del tipo System.ArgumentNullException más específico, aunque el último derive del anterior. Esto es importante porque le permite escribir una condición de excepción específica que se empareja a un tipo de excepción general seguido de un tipo de excepción más específico (con o sin una condición de excepción). El comportamiento del tiempo de ejecución se mantiene coherente con las versiones anteriores de C#; las excepciones se detectan mediante el primer bloque catch que coincide. La complexidad agregada es simplemente que la coincidencia de un bloque catch se determina mediante la combinación del tipo y la condición de la excepción, y el compilador solo aplica el orden relativo a los bloques catch sin condiciones de excepción. Por ejemplo, un bloque catch(System.Exception) con una condición de excepción puede aparecer antes de un bloque catch(System.Argument­Exception) con o sin una condición de excepción. Sin embargo, una vez aparece un bloque catch para un tipo de excepción sin una condición de excepción, no se puede producir un bloque catch de un bloque de excepción más específico (por ejemplo, catch(System.ArgumentNullException)) si tiene una condición de excepción. Esto proporciona al programador "flexibilidad" para codificar condiciones de excepción que posiblemente no siguen ningún orden: con condiciones de excepción anteriores que detectan excepciones destinadas a las posteriores y que, incluso, posiblemente representan las posteriores que, de forma involuntaria, no son accesibles. En definitiva, el orden de los bloques catch es similar a la manera en la que ordenaría instrucciones if-else. Una vez se cumple la condición, se ignoran los demás bloques catch. A diferencia de las condiciones de una instrucción if-else, sin embargo, todos los bloques catch debe incluir la comprobación del tipo de excepción.

Instrucciones de control de excepciones actualizadas

El ejemplo del operador de comparación de la Ilustración 1 es trivial, pero la condición de excepción no se limita a la simplicidad. Por ejemplo, podría hacer una llamada de método para validar una condición. El único requisito es que la expresión sea un predicado, que devuelve un valor booleano. En otras palabras, básicamente puede ejecutar cualquier código que quiera desde dentro de la cadena de llamada de la excepción catch. Esto ofrece la posibilidad de que no se vuelva a detectar y a generar la misma excepción de nuevo; puede limitar el contexto de manera suficiente antes de detectar la excepción para que solo se detecte cuando sea válida. Por lo tanto, se deben tener en cuenta las instrucciones que indican que se debe evitar la detección de excepciones que no se puede abordar en su totalidad. De hecho, es probable que cualquier comprobación condicional en torno a una instrucción throw vacía se pueda marcar con una noción de código y evitar. Considere la posibilidad de agregar una condición de excepción para tener que usar una instrucción throw vacía, salvo para conservar un estado volátil antes de que un proceso termine.

Dicho esto, los desarrolladores deberían limitar las cláusulas condicionales para comprobar únicamente el contexto. Esto es importante porque, si la misma expresión condicional genera una excepción, esa nueva excepción se ignore y la condición se trate como falsa. Por este motivo, debería evitar la generación de excepciones en la expresión condicional de excepciones.

Bloque catch general

C# necesita que cualquier objecto que genere el código derive de System.Exception. Sin embargo, este requisito no es universal para todos los lenguajes. C/C++, por ejemplo, permite que se genere cualquier tipo de objeto, como excepciones administradas que no derivan de System.Exception o incluso tipos primitivos como int o string. Comenzando por C# 2.0, todas las excepciones, tanto si derivan de System.Exception como si no, se propagarán en ensamblados de C# derivados de System.Exception. El resultado es que los bloques catch de System.Exception detectarán todas las excepciones "controladas de forma razonable" que no detectaban los bloques anteriores. Sin embargo, antes de C# 1.0, si no se generaba una excepción que no derivaba de System.Exception de un método de llamada (que se encuentra en un ensamblado que no se escribe en C#), no se detectaba la excepción mediante un bloque catch(System.Exception). Por este motivo, C# también admite un bloque catch general (catch{ }) que ahora se comporta de forma idéntica al bloque catch(System.Exception exception), salvo en que no hay ningún nombre de variable o tipo. La desventaja de dicho bloque consiste simplemente en que no hay ninguna instancia de excepción a la que acceder y, por lo tanto, no existe ninguna manera de determinar la forma adecuada de proceder. Tampoco sería posible registrar la excepción ni reconocer el caso improbable en el que dicha excepción sería inocua.

En la práctica, tanto el bloque catch(System.Exception) como el bloque catch general (a los que en este documento se hace referencia de forma genérica como bloque catch System.Exception) se deben evitar, excepto bajo el pretexto de "controlar" la excepción mediante un registro antes de cerrar el proceso. Siguiendo el principio general de la únicas excepciones del bloque catch que puede controlar, parecería presuntuoso escribir código para el que el programador declarara: este bloque cach puede controlar cualquier y todas las excepciones que pueden generarse. En primer lugar, el esfuerzo para catalogar todas las excepciones (especialmente en el cuerpo del bloque principal, en el que hay la mayor cantidad de código en ejecución y probablemente el menor contexto disponible) parece monumental excepto para el más simple de los programas. En segundo lugar, hay una serie de posibles excepciones que pueden generarse de forma inesperada.

Antes de C # 4.0, había un tercer conjunto de excepciones de estado dañadas para las que generalmente no se podía recuperar un programa. Sin embargo, este conjunto plantea menos problemas a partir de C # 4.0, debido a que, de hecho, la detección de System.Exception (o de un bloque catch general) no detectará dichas excepciones. (Técnicamente puede representar un método con System.Runtime.ExceptionServices.HandleProcessCorruptedStateExceptions para que incluso se detecten estas excepciones, pero la probabilidad de que se puedan abordar de manera suficiente es extremadamente baja. Consulte bit.ly/1FgeCU6 para obtener más información.)

Un dato técnico que debe tener en cuenta sobre las excepciones de estado dañadas es que solo omitirán los bloques catch System.Exception cuando se generen mediante el tiempo de ejecución. De hecho, se detectará una generación explícita de una excepción de estado dañada, como System.StackOverflowException o System.SystemException. Sin embargo, el hecho de generar dicha excepción sería muy confuso y realmente solo se admite por motivos de compatibilidad con versiones anteriores. La instrucciones actuales no consisten en generar ninguna de las excepciones de estado dañadas (como System.StackOverflowException, System.SystemException, System.OutOfMemoryException, System.Runtime.Interop­Services.COMException, System.Runtime.InteropServices.SEH­Exception y System.ExecutionEngineException).

En resumen, evite el uso de un bloque catch System.Exception, a menos que sea para controlar la excepción con algún código de limpieza, y el registro de una excepción antes de volver a generar otra o de cerrar la aplicación correctamente. Por ejemplo, si el bloque catch pudiera guardar correctamente los datos volátiles (cosa que no se puede suponer necesariamente porque también podría estar dañado) antes de cerrar la aplicación o volver a generar la excepción. Cuando se encuentre en un escenario para el que se deba finalizar la aplicación porque no es seguro continuar con su ejecución, el código debería invocar el método System.Environment.FailFast. Evite System.Exception y los bloques catch generales, excepto para registrar la excepción correctamente antes de cerrar la aplicación.

Resumen

En este artículo he proporcionado nuevas instrucciones para las actualizaciones en el control y la detección de excepciones derivadas de las mejoras llevadas a cabo en las últimas versiones de C# y .NET Framework. A pesar del hecho de que había algunas instrucciones nuevas, muchas otras siguen siendo tan firmes como antes. Aquí tiene un resumen de las instrucciones para la detección de excepciones:

  • EVITE la detección de excepciones que no puede abordar en su totalidad.
  • EVITE ocultar (descartar) las excepciones que no puede abordar en su totalidad.
  • USE la opción de generar para volver a generar una excepción, en lugar de generar <exception object> dentro de un bloque catch.
  • ESTABLEZCA la propiedad InnerException de la excepción encapsulada con la excepción detectada, salvo que al hacerlo se expongan datos privados.
  • TENGA EN CUENTA una condición de excepción para tener que volver a generar una excepción después de capturar una que no puede controlar.
  • EVITE la generación de excepciones de la expresión condicional de excepciones.
  • TENGA CUIDADO al volver a generar diferentes excepciones.
  • Use System.Exception y los bloques catch generales con poca frecuencia, únicamente para registrar la excepción antes de cerrar la aplicación.
  • EVITE los informes de excepciones o el registro en un nivel inferior de la pila de llamadas.

Visite itl.tc/ExceptionGuidelinesForCSharp para obtener una revisión de los detalles de cada uno de estas instrucciones. En la siguiente columna me centraré más en las instrucciones para generar excepciones. Baste con decir por ahora que un tema para generar excepciones es: El destinatario deseado de una excepción es más bien un programador que un usuario final de un programa.

Tenga en cuenta que gran parte de este material se ha extraído de la siguiente edición de mi libro, "Essential C# 6.0 (5th Edition)" (Addison-Wesley, 2015), que ya se encuentra disponible en itl.tc/EssentialCSharp.


Mark Michaelis es fundador de IntelliTect y trabaja de arquitecto técnico como jefe y formador. Durante casi dos décadas, ha sido MVP de Microsoft y director regional de Microsoft desde 2007. Michaelis trabaja con varios equipos de revisión de diseño de software de Microsoft, como C#, Microsoft Azure, SharePoint y Visual Studio ALM. Realiza ponencias en conferencias para desarrolladores y ha escrito varios libros, como "Essential C# 6.0 (5th Edition)", que es su publicación más reciente. Póngase en contacto con él en Facebook en facebook.com/Mark.Michaelis, en su blog IntelliTect.com/Mark, en Twitter @markmichaelis o a través de la dirección de correo electrónico mark@IntelliTect.com.

Gracias a los siguientes expertos técnicos por revisar este artículo: Kevin Bost, Jason Peterson y Mads Torgerson