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

23

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?

JᴀʏMᴇᴇ
la source
14
A try.. catchest un type spécifique de bloc de code, et dans la mesure où tous les blocs de code vont, vous ne pouvez pas déclarer une variable dans un et utiliser cette même variable dans un autre comme une question de portée.
Neil
msgstr "est un type spécifique de bloc de code". Spécifique de quelle manière? Merci
JᴀʏMᴇᴇ
7
Je veux dire que quelque chose entre des accolades est un bloc de code. Vous le voyez après une instruction if et une instruction for, bien que le concept soit le même. Le contenu est dans une portée élevée par rapport à sa portée parent. Je suis presque sûr que ce serait problématique si vous utilisiez simplement les accolades {}sans essayer.
Neil
Astuce: notez que l'utilisation de (IDisposable) {} et simplement {} s'applique également de la même manière. Lorsque vous utilisez une utilisation avec un IDisposable, il nettoiera automatiquement les ressources indépendamment du succès ou de l'échec. Il y a quelques exceptions à cela, comme toutes les classes auxquelles vous ne vous attendez pas à implémenter IDisposable ...
Julia McGuigan
1
Beaucoup de discussions sur cette même question sur StackOverflow, ici: stackoverflow.com/questions/94977/…
Jon Schneider

Réponses:

90

Et si votre code était:

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

try
{
   var secondVariable = firstVariable;
}
catch {}

Maintenant, vous essayez 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 indique «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.

Ben Aaronson
la source
Ahh, c'est exactement ce que je cherchais. Je savais que certaines fonctionnalités du langage rendaient impossible ce que je proposais, mais je n'ai pu proposer aucun scénario. Merci beaucoup.
JᴀʏMᴇᴇ
1
"Maintenant, vous essayez d'utiliser une variable non déclarée si votre appel de méthode est levé." De plus, supposons que cela ait été évité en traitant la variable comme si elle avait été déclarée, mais non initialisée, avant le code qui pourrait être lancé. Ensuite, il ne serait pas non déclaré, mais il serait toujours potentiellement non affecté, et une analyse d'affectation définitive interdirait de lire sa valeur (sans une affectation intermédiaire qui pourrait prouver qu'elle s'est produite).
Eliah Kagan
3
Pour un langage statique comme C #, la déclaration n'est vraiment pertinente qu'au moment de la compilation. Le compilateur pourrait facilement déplacer la déclaration plus tôt dans la portée. Le fait le plus important à l'exécution est que la variable peut ne pas être initialisée .
jpmc26
3
Je ne suis pas d'accord avec cette réponse. C # a déjà la règle selon laquelle les variables non initialisées ne peuvent pas être lues, avec une certaine connaissance du flux de données. (Essayez de déclarer des variables dans le cas de a switchet d'y accéder dans d'autres.) Cette règle pourrait facilement s'appliquer ici et empêcher de toute façon la compilation de ce code. Je pense que la réponse de Peter ci-dessous est plus plausible.
Sebastian Redl
2
Il y a une différence entre non déclaré et non initialisé et C # les suit séparément. Si vous étiez autorisé à utiliser une variable en dehors du bloc où elle a été déclarée, cela signifierait que vous seriez en mesure de l'affecter dans le premier catchbloc, puis elle serait définitivement affectée dans le deuxième trybloc.
svick
64

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 supposant que les try/catchblocs n'affectent pas la portée, vous vous retrouvez avec:

{
    // new scope here
}

try
{
   // Not new scope
}

Et pour moi cette tête se bloque dans le principe du moindre étonnement (POLA) parce que vous avez maintenant {et }faire double en fonction du 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 les try/catchblocs. Ce qui commence à ajouter une odeur de code. Donc, au moment où vous n'avez pas de portée try/catchdans la langue, cela aurait été un tel gâchis que vous auriez été mieux avec la version limitée.

Peter M
la source
Une autre excellente réponse. Et je n'avais jamais entendu parler de POLA, donc bonne lecture. Merci beaucoup mon pote.
JᴀʏMᴇᴇ
"Le seul moyen de sortir de ce bordel est de désigner un autre marqueur pour délimiter try/ catchbloquer." - tu veux dire try { { // scope } }:? :)
CompuChip
@CompuChip qui aurait une {}double fonction de tirage et non une création de portée en fonction du contexte. try^ //no-scope ^serait un exemple d'un marqueur différent.
Leliel
1
À mon avis, c'est la raison beaucoup plus fondamentale et plus proche de la "vraie" réponse.
Jack Aidley
@JackAidley d'autant plus que vous pouvez déjà écrire du code où vous utilisez une variable qui pourrait ne pas être affectée. Donc, bien que la réponse de Ben ait un point sur la façon dont il s'agit d'un comportement utile, je ne vois pas pourquoi ce comportement existe. La réponse de Ben note que le PO dit "à part la cohérence", mais la cohérence est une très bonne raison! L'étendue étroite présente toutes sortes d'autres avantages.
Kat
21

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 est nécessaire de 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 une analyse d'affectation définie et interdit de lire la valeur d'une variable dont il n'a pas été 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 une erreur de compilation :

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

J'ai appelé Environment.Exit dans le bloc catch, donc je sais que la variable a été assignée avant l'appel à 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 . N'étant pas en mesure de garantir que la fonction appelée ne lançait pas un IOException, le compilateur ne pouvait pas savoir que cela navait été attribué, et vous devriez alors 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 est préférable de ne faire quelque chose comme ça que si la valeur par défaut que vous lui donnez a du sens * 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 à l'intérieur d'un finallybloc attaché , car l'exécution peut laisser un bloc d'essai dans plus de situations que nous ne le pensons souvent .

* Soit dit en passant, certains langages, comme C et C ++, autorisent tous deux des variables non initialisées et n'ont pas d'analyse d'affectation définie pour empêcher leur lecture. Étant donné que la lecture de la mémoire non initialisée entraîne le comportement des programmes 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 d'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 peut être un choix raisonnable. Cependant, dans une application réelle, à moins que l'exception ne soit levée que dans des 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 qu'un plantage .

Peut-être vous vraiment ne voulez factoriser le code.

Dans votre exemple, vous introduisez firstVariableet secondVariableessayez des blocs. Comme je l'ai dit, vous pouvez les définir avant les blocs try dans lesquels ils sont assigné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 assigné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 faciliter la gestion des erreurs là 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 ce 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 firstVariableet secondVariable.

Une autre approche consiste à mettre le code qui utilise les variables dans le bloc try. C'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 en fait lever une exception, et aussi que vous ne planifiez pas vraiment de capturer toutes les exceptions possibles , mais simplement de capturer 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 puisque 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. Cela 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 pourriez 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 renvoie et déconstruit un ValueTuple avec la syntaxe du C # 7.0 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, il serait probablement préférable d'avoir deux méthodes distinctes de toute façon.

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 unDie méthode avec un messageparamètre.) La throw new InvalidOperationException();ligne n'est jamais réellement exécutée , vous n'avez donc pas besoin (et ne devez pas) écrire une clause catch pour elle.

En plus de quitter lorsqu'une erreur particulière se produit, vous pouvez parfois écrire du code qui ressemble à ceci si vous lancez une exception d'un autre type qui encapsule l'exception d'origine . (Dans cette situation, vous n'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 pratiquement aucun 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 la refactorisation peut être toujours votre meilleure option.

Eliah Kagan
la source
"Lorsque vous écrivez du code dans un bloc try avec des clauses catch, vous dites au compilateur et à tout lecteur humain qu'il doit être traité comme s'il n'était pas possible de l'exécuter." Ce qui importe au compilateur, c'est que le contrôle peut atteindre des instructions ultérieures même si les instructions précédentes lèvent une exception. Le compilateur suppose normalement que si une instruction lève une exception, l'instruction suivante ne sera pas exécutée, donc une variable non affectée ne sera pas lue. L'ajout d'un «catch» permettra au contrôle d'accéder à des déclarations ultérieures - c'est le catch qui compte, pas si le code du bloc try est lancé.
Pete Kirkham