En C #, ¿por qué las variables declaradas dentro de un bloque try tienen un alcance limitado?

23

Quiero agregar manejo de errores a:

var firstVariable = 1;
var secondVariable = firstVariable;

Lo siguiente no se compilará:

try
{
   var firstVariable = 1;
}
catch {}

try
{
   var secondVariable = firstVariable;
}
catch {}

¿Por qué es necesario que un bloque try catch afecte el alcance de las variables como lo hacen otros bloques de código? Dejando a un lado la coherencia, ¿no tendría sentido para nosotros poder envolver nuestro código con manejo de errores sin la necesidad de refactorizar?

JᴀʏMᴇᴇ
fuente
14
A try.. catches un tipo específico de bloque de código, y en lo que respecta a todos los bloques de código, no puede declarar una variable en uno y usar esa misma variable en otro como una cuestión de alcance.
Neil
"es un tipo específico de bloque de código". ¿Específico de qué manera? Gracias
JᴀʏMᴇᴇ
77
Quiero decir que cualquier cosa entre llaves es un bloque de código. Puede verlo después de una declaración if y después de una declaración for, aunque el concepto es el mismo. El contenido está en un ámbito elevado con respecto a su ámbito principal. Estoy bastante seguro de que esto sería problemático si solo usaras las llaves {}sin intentarlo.
Neil
Consejo: Tenga en cuenta que el uso de (IDisposable) {} y solo {} también se aplica de manera similar. Cuando usa un uso con un ID desechable, limpiará automáticamente los recursos independientemente del éxito o el fracaso. Hay algunas excepciones a esto, como no todas las clases que esperarías implementar IDisposable ...
Julia McGuigan
1
Mucha discusión sobre esta misma pregunta en StackOverflow, aquí: stackoverflow.com/questions/94977/…
Jon Schneider

Respuestas:

90

¿Qué pasa si su código era:

try
{
   MethodThatMightThrow();
   var firstVariable = 1;
}
catch {}

try
{
   var secondVariable = firstVariable;
}
catch {}

Ahora intentaría usar una variable no declarada ( firstVariable) si su llamada al método arroja.

Nota : El ejemplo anterior responde específicamente a la pregunta original, que dice "dejando de lado la coherencia". Esto demuestra que hay otras razones además de la consistencia. Pero como muestra la respuesta de Peter, también hay un poderoso argumento de coherencia, que seguramente habría sido un factor muy importante en la decisión.

Ben Aaronson
fuente
Ahh, esto es exactamente lo que estaba buscando. Sabía que había algunas características del lenguaje que hacían imposible lo que sugería, pero no pude encontrar ningún escenario. Muchas gracias.
JᴀʏMᴇᴇ
1
"Ahora estarías intentando usar una variable no declarada si tu llamada al método se lanza". Además, suponga que esto se evita tratando la variable como si hubiera sido declarada, pero no inicializada, antes del código que podría arrojar. Entonces no se declararía, pero aún estaría potencialmente sin asignar, y el análisis definitivo de la asignación prohibiría leer su valor (sin una asignación intermedia que pudiera probar que ocurrió).
Eliah Kagan
3
Para un lenguaje estático como C #, la declaración solo es relevante en tiempo de compilación. El compilador podría mover fácilmente la declaración anteriormente en el alcance. El hecho más importante en tiempo de ejecución es que la variable podría no inicializarse .
jpmc26
3
No estoy de acuerdo con esta respuesta. C # ya tiene la regla de que las variables no inicializadas no se pueden leer, con cierto conocimiento del flujo de datos. (Intente declarar variables en casos de a switchy acceder a ellas en otros). Esta regla podría aplicarse fácilmente aquí y evitar que este código se compile de todos modos. Creo que la respuesta de Peter a continuación es más plausible.
Sebastian Redl
2
Hay una diferencia entre no declarados y no iniciados y C # los rastrea por separado. Si se le permitiera usar una variable fuera del bloque donde se declaró, significaría que podría asignarla en el primer catchbloque y luego definitivamente se asignaría en el segundo trybloque.
svick
64

Sé que Ben ha respondido bien a esto, pero quería abordar el punto de vista de coherencia que convenientemente se hizo a un lado. Suponiendo que los try/catchbloques no afectaron el alcance, terminaría con:

{
    // new scope here
}

try
{
   // Not new scope
}

Y para mí, esto choca de frente con el Principio de menor asombro (POLA) porque ahora tienes el doble deber {y el }deber dependiendo del contexto de lo que los precedió.

La única forma de salir de este desastre es designar algún otro marcador para delinear try/catchbloques. Que comienza a agregar un código de olor. Entonces, para cuando no tenga alcance try/catchen el idioma, habría sido un desastre que hubiera sido mejor con la versión de alcance.

Peter M
fuente
Otra excelente respuesta. Y nunca había oído hablar de POLA, tan agradable lectura adicional. Muchas gracias amigo.
JᴀʏMᴇᴇ
"La única forma de salir de este desastre es designar algún otro marcador para delinear try/ catchbloquear". - quieres decir try { { // scope } }:? :)
CompuChip
@CompuChip que tendría un {}doble deber como alcance y no creación de alcance dependiendo del contexto todavía. try^ //no-scope ^sería un ejemplo de un marcador diferente.
Leliel
1
En mi opinión, esta es la razón mucho más fundamental y más cercana a la respuesta "real".
Jack Aidley
@JackAidley especialmente porque ya puedes escribir código donde usas una variable que podría no estar asignada. Entonces, aunque la respuesta de Ben tiene un punto sobre cómo este es un comportamiento útil, no veo por qué existe el comportamiento. La respuesta de Ben nota que el OP dice "dejando de lado la coherencia", ¡pero la consistencia es una razón perfecta! El alcance estrecho tiene todo tipo de otros beneficios.
Kat
21

Dejando a un lado la coherencia, ¿no tendría sentido para nosotros poder envolver nuestro código con manejo de errores sin la necesidad de refactorizar?

Para responder a esto, es necesario mirar más allá del alcance de una variable .

Incluso si la variable permaneciera dentro del alcance, no se asignaría definitivamente .

La declaración de la variable en el bloque try expresa, para el compilador y para los lectores humanos, que solo tiene sentido dentro de ese bloque. Es útil para el compilador hacer cumplir eso.

Si desea que la variable esté dentro del alcance después del bloque try, puede declararla fuera del bloque:

var zerothVariable = 1_000_000_000_000L;
int firstVariable;

try {
    // Change checked to unchecked to allow the overflow without throwing.
    firstVariable = checked((int)zerothVariable);
}
catch (OverflowException e) {
    Console.Error.WriteLine(e.Message);
    Environment.Exit(1);
}

Eso expresa que la variable puede ser significativa fuera del bloque try. El compilador lo permitirá.

Pero también muestra otra razón por la que generalmente no sería útil mantener variables en el alcance después de introducirlas en un bloque try. El compilador de C # realiza un análisis de asignación definitivo y prohíbe leer el valor de una variable que no ha demostrado que se le haya dado un valor. Entonces todavía no puede leer de la variable.

Supongamos que intento leer de la variable después del bloque try:

Console.WriteLine(firstVariable);

Eso dará un error en tiempo de compilación :

CS0165 Uso de la variable local no asignada 'firstVariable'

Llamé a Environment.Exit en el bloque catch, por lo que sé que la variable se ha asignado antes de la llamada a Console.WriteLine. Pero el compilador no infiere esto.

¿Por qué es tan estricto el compilador?

Ni siquiera puedo hacer esto:

int n;

try {
    n = 10; // I know this won't throw an IOException.
}
catch (IOException) {
}

Console.WriteLine(n);

Una forma de ver esta restricción es decir que el análisis de asignación definitiva en C # no es muy sofisticado. Pero otra forma de verlo es que, cuando escribe código en un bloque de prueba con cláusulas catch, le está diciendo tanto al compilador como a los lectores humanos que debe tratarse como si no todos pudieran ejecutarse.

Para ilustrar lo que quiero decir, imagine si el compilador permitió el código anterior, pero luego agregó una llamada en el bloque try a una función que personalmente sabe que no arrojará una excepción . Al no poder garantizar que la función llamada no arrojó un IOException, el compilador no podía saber que nestaba asignado, y luego tendría que refactorizar.

Esto quiere decir que, al renunciar a un análisis altamente sofisticado para determinar si una variable asignada en un bloque de prueba con cláusulas catch se ha asignado definitivamente después, el compilador lo ayuda a evitar escribir código que probablemente se rompa más tarde. (Después de todo, detectar una excepción generalmente significa que crees que se podría lanzar una).

Puede asegurarse de que la variable se asigne a través de todas las rutas de código.

Puede compilar el código dando a la variable un valor antes del bloque try o en el bloque catch. De esa manera, aún se habrá inicializado o asignado, incluso si la asignación en el bloque try no tiene lugar. Por ejemplo:

var n = 0; // But is this meaningful, or just covering a bug?

try {
    n = 10;
}
catch (IOException) {
}

Console.WriteLine(n);

O:

int n;

try {
    n = 10;
}
catch (IOException) {
    n = 0; // But is this meaningful, or just covering a bug?
}

Console.WriteLine(n);

Aquellos compilan. Pero es mejor hacer algo así si el valor predeterminado que le da tiene sentido * y produce un comportamiento correcto.

Tenga en cuenta que, en este segundo caso, donde asigna la variable en el bloque try y en todos los bloques catch, aunque puede leer la variable después del try-catch, aún no podrá leer la variable dentro de un finallybloque adjunto , porque la ejecución puede dejar un bloque de prueba en más situaciones de las que a menudo pensamos .

* Por cierto, algunos lenguajes, como C y C ++, permiten variables no inicializadas y no tienen un análisis de asignación definitivo para evitar su lectura. Debido a que leer memoria no inicializada hace que los programas se comporten de manera no determinista y errática , generalmente se recomienda evitar introducir variables en esos idiomas sin proporcionar un inicializador. En lenguajes con análisis de asignación definidos como C # y Java, el compilador le evita leer variables no inicializadas y también el mal menor de inicializarlas con valores sin sentido que luego pueden malinterpretarse como significativos.

Puede hacerlo para que las rutas de código donde la variable no está asignada arroje una excepción (o retorno).

Si planea realizar alguna acción (como iniciar sesión) y volver a lanzar la excepción o lanzar otra excepción, y esto sucede en cualquier cláusula catch donde la variable no está asignada, entonces el compilador sabrá que la variable ha sido asignada:

int n;

try {
    n = 10;
}
catch (IOException e) {
    Console.Error.WriteLine(e.Message);
    throw;
}

Console.WriteLine(n);

Eso compila, y bien puede ser una opción razonable. Sin embargo, en una aplicación real, a menos que la excepción solo se produzca en situaciones en las que ni siquiera tiene sentido tratar de recuperarse * , debe asegurarse de que todavía lo está atrapando y manejando adecuadamente en algún lugar .

(Tampoco puede leer la variable en un bloque finalmente en esta situación, pero no parece que deba ser capaz, después de todo, los bloques finalmente siempre se ejecutan, y en este caso la variable no siempre se asigna .)

* Por ejemplo, muchas aplicaciones no tienen una cláusula catch que maneje una excepción OutOfMemoryException porque cualquier cosa que puedan hacer al respecto podría ser al menos tan mala como bloquearse .

Tal vez usted realmente no desea refactorizar el código.

En su ejemplo, introduce firstVariabley secondVariableprueba bloques. Como he dicho, puede definirlos antes de los bloques de prueba en los que están asignados para que luego permanezcan dentro del alcance, y puede satisfacer / engañar al compilador para que le permita leer de ellos asegurándose de que siempre estén asignados.

Pero el código que aparece después de esos bloques probablemente depende de que se hayan asignado correctamente. Si ese es el caso, entonces su código debe reflejar y garantizar eso.

Primero, ¿puede (y debería) manejar el error allí? Una de las razones por las que existe el manejo de excepciones es para facilitar el manejo de errores donde pueden manejarse de manera efectiva , incluso si eso no está cerca de donde ocurren.

Si no puede manejar el error en la función que se inicializó y usa esas variables, entonces quizás el bloque try no debería estar en esa función, sino en algún lugar más alto (es decir, en un código que llama a esa función o código eso llama a ese código). Solo asegúrate de no estar atrapando accidentalmente una excepción lanzada en otro lugar y asumiendo erróneamente que fue lanzada mientras se inicializa firstVariabley secondVariable.

Otro enfoque es colocar el código que usa las variables en el bloque try. Esto a menudo es razonable. Una vez más, si las mismas excepciones que está captando de sus inicializadores también se pueden generar desde el código circundante, debe asegurarse de no descuidar esa posibilidad cuando las maneje.

(Supongo que está inicializando las variables con expresiones más complicadas que las que se muestran en sus ejemplos, de modo que en realidad podrían arrojar una excepción, y también que realmente no está planeando capturar todas las excepciones posibles , sino solo detectar las excepciones específicas puede anticipar y manejar de manera significativa . Es cierto que el mundo real no siempre es tan agradable y el código de producción a veces hace esto , pero dado que su objetivo aquí es manejar los errores que ocurren mientras se inicializan dos variables específicas, cualquier cláusula de captura que escriba para ese específico El propósito debe ser específico para los errores que sean).

Una tercera forma es extraer el código que puede fallar, y el try-catch que lo maneja, en su propio método. Esto es útil si primero desea lidiar con los errores por completo, y luego no preocuparse por detectar inadvertidamente una excepción que debería ser manejada en otro lugar.

Supongamos, por ejemplo, que desea salir inmediatamente de la aplicación si no se asigna ninguna de las variables. (Obviamente, no todo el manejo de excepciones es para errores fatales; este es solo un ejemplo, y puede o no ser cómo desea que su aplicación reaccione al problema). Podría hacer algo como esto:

// In real life, this should be named more descriptively.
private static (int firstValue, int secondValue) GetFirstAndSecondValues()
{
    try {
        // This code is contrived. The idea here is that obtaining the values
        // could actually fail, and throw a SomeSpecificException.
        var firstVariable = 1;
        var secondVariable = firstVariable;
        return (firstVariable, secondVariable);
    }
    catch (SomeSpecificException e) {
        Console.Error.WriteLine(e.Message);
        Environment.Exit(1);
        throw new InvalidOperationException(); // unreachable
    }
}

// ...and of course so should this.
internal static void MethodThatUsesTheValues()
{
    var (firstVariable, secondVariable) = GetFirstAndSecondValues();

    // Code that does something with them...
}

Ese código regresa y deconstruye un ValueTuple con la sintaxis de C # 7.0 para devolver múltiples valores, pero si todavía está en una versión anterior de C #, aún puede usar esta técnica; por ejemplo, puede usar parámetros o devolver un objeto personalizado que proporcione ambos valores . Además, si las dos variables no están realmente estrechamente relacionadas, probablemente sería mejor tener dos métodos separados de todos modos.

Especialmente si tiene múltiples métodos como ese, debería considerar centralizar su código para notificar al usuario de errores fatales y dejar de fumar. (Por ejemplo, podría escribir un Diemétodo con un messageparámetro). La throw new InvalidOperationException();línea nunca se ejecuta realmente, por lo que no necesita (y no debe) escribir una cláusula catch para ella.

Además de salir cuando se produce un error en particular, a veces puede escribir código que se vea así si lanza una excepción de otro tipo que envuelve la excepción original . (En esa situación, no necesitaría una segunda expresión de lanzamiento inalcanzable).

Conclusión: el alcance es solo una parte de la imagen.

Puede lograr el efecto de envolver su código con manejo de errores sin refactorizar (o, si lo prefiere, sin casi ninguna refactorización), simplemente separando las declaraciones de las variables de sus asignaciones. El compilador permite esto si cumple con las reglas de asignación definidas de C #, y si declara una variable antes del bloque try deja en claro su alcance mayor. Pero refactorizar aún más puede ser su mejor opción.

Eliah Kagan
fuente
"cuando escribes código en un bloque de prueba con cláusulas catch, le estás diciendo tanto al compilador como a los lectores humanos que debería tratarse como si no todos pudieran ejecutarse". Lo que le importa al compilador es que el control puede alcanzar declaraciones posteriores incluso si las declaraciones anteriores arrojan una excepción. El compilador normalmente supone que si una instrucción arroja una excepción, la siguiente instrucción no se ejecutará, por lo que no se leerá una variable no asignada. Agregar un 'catch' permitirá que el control llegue a declaraciones posteriores: lo que importa es la captura, no si el código en el bloque try se lanza.
Pete Kirkham