web-dev-qa-db-fra.com

En C #, pourquoi les variables déclarées dans un bloc try ont-elles une portée limitée?

Je souhaite ajouter la gestion des erreurs à:

var firstVariable = 1;
var secondVariable = firstVariable;

Ce qui suit ne compilera pas:

try
{
   var firstVariable = 1;
}
catch {}

try
{
   var secondVariable = firstVariable;
}
catch {}

Pourquoi est-il nécessaire qu'un bloc try catch affecte la portée des variables comme le font d'autres blocs de code? Mis à part la cohérence, ne serait-il pas logique pour nous de pouvoir envelopper notre code avec la gestion des erreurs sans avoir besoin de refactoriser?

24
JᴀʏMᴇᴇ

Et si votre code était:

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

try
{
   var secondVariable = firstVariable;
}
catch {}

Vous essayez maintenant d'utiliser une variable non déclarée (firstVariable) si votre appel de méthode est lancé.

Remarque: l'exemple ci-dessus répond spécifiquement à la question d'origine, qui stipule "cohérence à part". Cela démontre qu'il existe des raisons autres que la cohérence. Mais comme le montre la réponse de Peter, il y a aussi un argument puissant de cohérence, qui aurait certainement été un facteur très important dans la décision.

91
Ben Aaronson

Je sais que cela a été bien répondu par Ben, mais je voulais aborder la cohérence POV qui a été commodément écartée. En admettant que try/catch les blocs n'affectaient pas la portée, vous vous retrouveriez avec:

{
    // new scope here
}

try
{
   // Not new scope
}

Et pour moi, cela s'écrase de plein fouet dans le Principe du moindre étonnement (POLA) parce que vous avez maintenant le { et } faisant double emploi selon le contexte de ce qui les a précédés.

La seule façon de sortir de ce gâchis est de désigner un autre marqueur pour délimiter try/catch blocs. Ce qui commence à ajouter une odeur de code. Donc, au moment où vous avez sans portée try/catch dans la langue, cela aurait été un tel gâchis que vous auriez été mieux avec la version scoped.

64
Peter M

Mis à part la cohérence, ne serait-il pas logique pour nous de pouvoir envelopper notre code avec la gestion des erreurs sans avoir besoin de refactoriser?

Pour répondre à cela, il faut regarder plus que la portée d'une variable .

Même si la variable restait dans la portée, elle ne serait pas définitivement attribuée .

La déclaration de la variable dans le bloc try exprime - au compilateur et aux lecteurs humains - qu'elle n'a de sens qu'à l'intérieur de ce bloc. Il est utile que le compilateur applique cela.

Si vous voulez que la variable soit dans la portée après le bloc try, vous pouvez la déclarer en dehors du bloc:

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);
}

Cela exprime que la variable peut être significative en dehors du bloc try. Le compilateur le permettra.

Mais cela montre également une autre raison pour laquelle il ne serait généralement pas utile de conserver les variables dans la portée après les avoir introduites dans un bloc try. Le compilateur C # effectue analyse d'affectation définitive et interdit de lire la valeur d'une variable dont il n'a pas prouvé qu'elle a reçu une valeur. Vous ne pouvez donc toujours pas lire la variable.

Supposons que j'essaie de lire la variable après le bloc try:

Console.WriteLine(firstVariable);

Cela donnera ne erreur de compilation :

CS0165 Utilisation de la variable locale non affectée 'firstVariable'

J'ai appelé Environment.Exit dans le bloc catch, donc [~ # ~] i [~ # ~] sais que la variable a été assignée avant l'appel à la console .WriteLine. Mais le compilateur ne déduit pas cela.

Pourquoi le compilateur est-il si strict?

Je ne peux même pas faire ça:

int n;

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

Console.WriteLine(n);

Une façon de considérer cette restriction est de dire que l'analyse d'affectation définitive en C # n'est pas très sophistiquée. Mais une autre façon de voir les choses est que, lorsque vous écrivez du code dans un bloc try avec des clauses catch, vous dites au compilateur et à tous les lecteurs humains qu'il doit être traité comme s'il ne pouvait pas tous s'exécuter.

Pour illustrer ce que je veux dire, imaginez si le compilateur a autorisé le code ci-dessus, mais vous avez ensuite ajouté un appel dans le bloc try à une fonction que vous savez personnellement ne lèvera pas d'exception. Ne pouvant garantir que la fonction appelée ne lançait pas un IOException, le compilateur ne pouvait pas savoir que n était assigné, et alors vous auriez refactoriser.

Cela signifie qu'en renonçant à une analyse très sophistiquée pour déterminer si une variable affectée dans un bloc try avec des clauses catch a été définitivement affectée par la suite, le compilateur vous aide à éviter d'écrire du code susceptible de se casser plus tard. (Après tout, attraper une exception signifie généralement que vous pensez qu'une peut être levée.)

Vous pouvez vous assurer que la variable est affectée via tous les chemins de code.

Vous pouvez faire compiler le code en donnant à la variable une valeur avant le bloc try ou dans le bloc catch. De cette façon, il aura toujours été initialisé ou affecté, même si l'affectation dans le bloc try n'a pas lieu. Par exemple:

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

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

Console.WriteLine(n);

Ou:

int n;

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

Console.WriteLine(n);

Ceux-ci se compilent. Mais il vaut mieux ne faire quelque chose comme ça que si la valeur par défaut que vous lui donnez est logique* et produit un comportement correct.

Notez que, dans ce deuxième cas où vous affectez la variable dans le bloc try et dans tous les blocs catch, bien que vous puissiez lire la variable après le try-catch, vous ne pourrez toujours pas lire la variable dans un attaché finally block , car l'exécution peut laisser un bloc try dans plus de situations que nous ne le pensons souvent .

Soit dit en passant, certains langages, comme C et C++, tous les deux autorisent les variables non initialisées et pas ont une analyse d'affectation définie pour empêcher leur lecture. Parce que la lecture de la mémoire non initialisée fait que les programmes se comportent de manière non déterministe et erratique , il est généralement recommandé d'éviter d'introduire des variables dans ces langues sans fournir un initialiseur. Dans les langages avec une analyse d'affectation définie comme C # et Java, le compilateur vous évite de lire des variables non initialisées et aussi du moindre mal de les initialiser avec des valeurs dénuées de sens qui peuvent plus tard être mal interprétées comme significatives.

Vous pouvez faire en sorte que les chemins de code où la variable n'est pas affectée lèvent une exception (ou retournent).

Si vous prévoyez d'effectuer une action (comme la journalisation) et de renvoyer l'exception ou de lever une autre exception, et cela se produit dans toutes les clauses catch où la variable n'est pas affectée, le compilateur saura que la variable a été affectée:

int n;

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

Console.WriteLine(n);

Cela compile, et pourrait bien être un choix raisonnable. Cependant, dans une application réelle, à moins que l'exception ne soit lancée situations où cela n'a même pas de sens d'essayer de récupérer*, vous devez vous assurer que vous êtes toujours en train de l'attraper et de le manipuler correctement quelque part.

(Vous ne pouvez pas non plus lire la variable dans un bloc finally dans cette situation, mais il ne semble pas que vous devriez pouvoir - après tout, les blocs finalement fonctionnent toujours essentiellement, et dans ce cas, la variable n'est pas toujours affectée .)

Par exemple, de nombreuses applications n'ont pas de clause catch qui gère une OutOfMemoryException car tout ce qu'elles pourraient faire à ce sujet pourrait être au moins aussi mauvais que le crash .

Peut-être que vous voulez vraiment faire vouloir refactoriser le code.

Dans votre exemple, vous introduisez firstVariable et secondVariable dans les blocs try. Comme je l'ai dit, vous pouvez les définir avant les blocs try dans lesquels ils sont affectés afin qu'ils restent dans la portée par la suite, et vous pouvez satisfaire/tromper le compilateur en vous permettant de lire à partir d'eux en vous assurant qu'ils sont toujours affectés.

Mais le code qui apparaît après ces blocs dépend probablement de leur affectation correcte. Si tel est le cas, votre code doit refléter et garantir cela.

Premièrement, pouvez-vous (et devriez-vous) gérer l'erreur là-bas? L'une des raisons pour lesquelles la gestion des exceptions existe est de la faire plus facile à gérer les erreurs où elles peuvent être gérées efficacement , même si ce n'est pas près de l'endroit où elles se produisent.

Si vous ne pouvez pas réellement gérer l'erreur dans la fonction qui a initialisé et utilise ces variables, alors peut-être que le bloc try ne devrait pas du tout être dans cette fonction, mais plutôt quelque part plus haut (c'est-à-dire, dans le code qui appelle cette fonction, ou code qui appelle ça code). Assurez-vous simplement que vous n'attrapez pas accidentellement une exception levée ailleurs et supposez à tort qu'elle a été levée lors de l'initialisation de firstVariable et secondVariable.

Une autre approche consiste à mettre le code qui utilise les variables dans le bloc try. Ceci est souvent raisonnable. Encore une fois, si les mêmes exceptions que vous attrapez de leurs initialiseurs peuvent également être levées du code environnant, vous devez vous assurer que vous ne négligez pas cette possibilité lors de leur manipulation.

(Je suppose que vous initialisez les variables avec des expressions plus compliquées que celles montrées dans vos exemples, de sorte qu'elles pourraient réellement lever une exception, et aussi que vous ne prévoyez pas vraiment pour attraper tout exceptions possibles , mais juste pour intercepter toutes les exceptions spécifiques vous pouvez anticiper et gérer de manière significative . Il est vrai que le monde réel n'est pas toujours aussi agréable et le code de production le fait parfois , mais comme votre objectif ici est de gérer les erreurs qui se produisent lors de l'initialisation de deux variables spécifiques, toutes les clauses catch que vous écrivez pour ce spécifique Le but doit être spécifique à toutes les erreurs.)

Une troisième façon consiste à extraire le code qui peut échouer et le try-catch qui le gère, dans sa propre méthode. Ceci est utile si vous souhaitez d'abord traiter complètement les erreurs, puis ne vous inquiétez pas d'attraper par inadvertance une exception qui devrait être gérée ailleurs à la place.

Supposons, par exemple, que vous souhaitiez quitter immédiatement l'application en cas d'échec de l'affectation de l'une ou l'autre variable. (Évidemment, toute la gestion des exceptions n'est pas destinée aux erreurs fatales; ce n'est qu'un exemple, et peut ou non être la façon dont vous voulez que votre application réagisse au problème.) Vous pouvez donc quelque chose comme ceci:

// 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...
}

Ce code retourne et déconstruit un ValueTuple avec la syntaxe du C # 7. pour renvoyer plusieurs valeurs, mais si vous utilisez toujours une version antérieure de C #, vous pouvez toujours utiliser cette technique; par exemple, vous pouvez utiliser des paramètres ou renvoyer un objet personnalisé qui fournit les deux valeurs . De plus, si les deux variables ne sont pas réellement étroitement liées, ce serait probablement mieux d'avoir de toute façon deux méthodes distinctes.

Surtout si vous avez plusieurs méthodes comme celle-ci, vous devriez envisager de centraliser votre code pour avertir l'utilisateur des erreurs fatales et quitter. (Par exemple, vous pouvez écrire une méthode Die avec un paramètre message.) La ligne throw new InvalidOperationException(); n'est jamais réellement exécutée vous n'avez donc pas besoin (et ne devrait pas) écrire une clause catch pour cela.

Mis à part la fermeture lorsqu'une erreur particulière se produit, vous pouvez parfois écrire du code qui ressemble à ceci si vous jetez ne exception d'un autre type qui encapsule l'exception d'origine . (Dans cette situation, vous auriez pas besoin d'une seconde expression de lancement inaccessible.)

Conclusion: la portée n'est qu'une partie de l'image.

Vous pouvez obtenir l'effet d'encapsuler votre code avec une gestion des erreurs sans refactoring (ou, si vous préférez, avec à peine tout refactoring), simplement en séparant les déclarations des variables de leurs affectations. Le compilateur permet cela si vous respectez les règles d'affectation définies de C #, et déclarer une variable avant le bloc try rend sa portée plus claire. Mais une refactorisation plus poussée peut toujours être votre meilleure option.

21
Eliah Kagan