Em C #, por que as variáveis ​​declaradas dentro de um bloco try têm escopo limitado?

23

Quero adicionar tratamento de erros a:

var firstVariable = 1;
var secondVariable = firstVariable;

O abaixo não será compilado:

try
{
   var firstVariable = 1;
}
catch {}

try
{
   var secondVariable = firstVariable;
}
catch {}

Por que é necessário que um bloco try catch afete o escopo das variáveis ​​como outros blocos de código? Com relação à consistência à parte, não faria sentido conseguir quebrar nosso código com o tratamento de erros sem a necessidade de refatorar?

JᴀʏMᴇᴇ
fonte
14
A try.. catché um tipo específico de bloco de código e, no que diz respeito a todos os blocos de código, você não pode declarar uma variável em um e usar a mesma variável em outro como uma questão de escopo.
194 Neil
"é um tipo específico de bloco de código". Específico de que maneira? Obrigado
JᴇᴇMᴇᴇ
7
Quero dizer, qualquer coisa entre colchetes é um bloco de código. Você vê isso após uma declaração if e após uma declaração for, embora o conceito seja o mesmo. O conteúdo está em um escopo elevado em relação ao seu escopo pai. Tenho certeza de que isso seria problemático se você usasse os colchetes {}sem a tentativa.
194 Neil
Dica: observe que usar (IDisposable) {} e apenas {} se aplica da mesma forma. Quando você usa um IDisposable, ele limpa automaticamente os recursos, independentemente de sucesso ou falha. Existem algumas exceções a este, como nem todas as classes que você esperaria implementar IDisposable ...
Julia McGuigan
1
Muita discussão sobre essa mesma pergunta em StackOverflow, aqui: stackoverflow.com/questions/94977/...
Jon Schneider

Respostas:

90

E se o seu código fosse:

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

try
{
   var secondVariable = firstVariable;
}
catch {}

Agora você estaria tentando usar uma variável não declarada ( firstVariable) se sua chamada de método for lançada.

Nota : O exemplo acima responde especificamente à pergunta original, que indica "sake de consistência à parte". Isso demonstra que há outras razões além da consistência. Mas, como mostra a resposta de Pedro, também há um argumento poderoso da consistência, que com certeza teria sido um fator muito importante na decisão.

Ben Aaronson
fonte
Ahh, isso é exatamente o que eu estava procurando. Eu sabia que havia alguns recursos de linguagem que tornavam impossível o que eu estava sugerindo, mas não conseguia apresentar nenhum cenário. Muito obrigado.
19417
1
"Agora você tentaria usar uma variável não declarada se a sua chamada de método fosse lançada." Além disso, suponha que isso fosse evitado tratando a variável como se ela tivesse sido declarada, mas não inicializada, antes do código que poderia ser lançado. Então não seria declarado, mas ainda seria potencialmente não atribuído, e a análise de atribuição definida proibiria a leitura de seu valor (sem uma atribuição intermediária que pudesse provar ocorrer).
Elias Kagan
3
Para uma linguagem estática como C #, a declaração é realmente relevante apenas no momento da compilação. O compilador pode facilmente mover a declaração anteriormente no escopo. O fato mais importante no tempo de execução é que a variável pode não ser inicializada .
precisa
3
Eu discordo desta resposta. O C # já possui a regra de que variáveis ​​não inicializadas não podem ser lidas, com alguma percepção do fluxo de dados. (Tente declarar variáveis ​​nos casos de a switche acessá-las em outras.) Essa regra pode ser aplicada facilmente aqui e impedir que esse código seja compilado de qualquer maneira. Eu acho que a resposta de Peter abaixo é mais plausível.
Sebastian Redl
2
Há uma diferença entre não declarado e não inicializado e o C # os rastreia separadamente. Se você pudesse usar uma variável fora do bloco em que foi declarada, isso significaria que você seria capaz de atribuir a ela no primeiro catchbloco e, em seguida, ela seria definitivamente atribuída no segundo trybloco.
svick
64

Eu sei que isso foi bem respondido por Ben, mas eu queria abordar o ponto de vista da consistência que foi convenientemente deixado de lado. Supondo que os try/catchblocos não afetassem o escopo, você terminaria com:

{
    // new scope here
}

try
{
   // Not new scope
}

E, para mim, isso colide com o princípio do menor espanto (POLA), porque agora você tem o dever {e }cumpre o duplo dever, dependendo do contexto do que os precedeu.

A única maneira de sair dessa bagunça é designar outro marcador para delinear try/catchblocos. O que começa a adicionar um cheiro de código. Então, quando você não tem escopo try/catchno idioma, teria sido uma bagunça que você estaria melhor com a versão com escopo.

Peter M
fonte
Outra excelente resposta. E eu nunca tinha ouvido falar em POLA, tão boa leitura adicional. Muito obrigado companheiro.
21917 JhMd
"A única maneira de sair dessa bagunça é designar outro marcador para delinear try/ catchbloquear". - você quer dizer try { { // scope } }:? :)
CompuChip
@CompuChip que teria {}um dever duplo como escopo e não criação de escopo, dependendo do contexto ainda. try^ //no-scope ^seria um exemplo de um marcador diferente.
Leliel 20/07
1
Na minha opinião, essa é a razão muito mais fundamental e mais próxima da resposta "real".
Jack Aidley
@ JackAidley, especialmente porque você já pode escrever o código em que usa uma variável que pode não estar atribuída. Portanto, embora a resposta de Ben tenha um argumento sobre como isso é um comportamento útil, não vejo por que o comportamento existe. A resposta de Ben nota o OP dizendo "o bem da consistência", mas a consistência é uma razão perfeitamente boa! O escopo estreito tem todos os tipos de outros benefícios.
Kat
21

Com relação à consistência à parte, não faria sentido conseguir quebrar nosso código com o tratamento de erros sem a necessidade de refatorar?

Para responder a isso, é necessário examinar mais do que apenas o escopo de uma variável .

Mesmo que a variável permanecesse no escopo, ela não seria definitivamente atribuída .

Declarar a variável no bloco try expressa - para o compilador e para os leitores humanos - que isso só é significativo dentro desse bloco. É útil para o compilador impor isso.

Se você deseja que a variável esteja no escopo após o bloco try, você pode declará-la fora do bloco:

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

Isso expressa que a variável pode ser significativa fora do bloco try. O compilador permitirá isso.

Mas também mostra outro motivo pelo qual normalmente não seria útil manter as variáveis ​​no escopo depois de introduzi-las em um bloco try. O compilador C # executa uma análise de atribuição definida e proíbe a leitura do valor de uma variável que ela não provou ter recebido um valor. Portanto, você ainda não pode ler da variável.

Suponha que eu tente ler a variável após o bloco try:

Console.WriteLine(firstVariable);

Isso dará um erro em tempo de compilação :

CS0165 Uso da variável local não atribuída 'firstVariable'

Chamei Environment.Exit no bloco catch, para que eu saiba que a variável foi atribuída antes da chamada para Console.WriteLine. Mas o compilador não infere isso.

Por que o compilador é tão rigoroso?

Eu não posso nem fazer isso:

int n;

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

Console.WriteLine(n);

Uma maneira de analisar essa restrição é dizer que a análise de atribuição definida em C # não é muito sofisticada. Mas outra maneira de analisar é que, quando você escreve código em um bloco try com cláusulas catch, está dizendo ao compilador e a qualquer leitor humano que ele deva ser tratado como se nem todos pudessem ser executados.

Para ilustrar o que quero dizer, imagine se o compilador permitiu o código acima, mas você adicionou uma chamada no bloco try a uma função que você sabe que pessoalmente não lançará uma exceção . Não sendo possível garantir que a função chamada não gerou um IOException, o compilador não sabia o que nfoi atribuído e, então, você teria que refatorar.

Isso significa que, ao excluir a análise altamente sofisticada para determinar se uma variável atribuída em um bloco try com cláusulas catch foi definitivamente atribuída posteriormente, o compilador ajuda a evitar a gravação de código que provavelmente quebrará mais tarde. (Afinal, capturar uma exceção geralmente significa que você acha que uma pode ser lançada.)

Você pode garantir que a variável seja atribuída por todos os caminhos de código.

Você pode compilar o código, atribuindo à variável um valor antes do bloco try ou no bloco catch. Dessa forma, ele ainda terá sido inicializado ou atribuído, mesmo que a atribuição no bloco try não ocorra. Por exemplo:

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

Aqueles compilam. Mas é melhor fazer algo assim apenas se o valor padrão que você der faz sentido * e produz um comportamento correto.

Observe que, neste segundo caso, em que você atribui a variável no bloco try e em todos os blocos catch, embora seja possível ler a variável após o try-catch, você ainda não seria capaz de ler a variável dentro de um finallybloco anexado , porque a execução pode deixar um bloco de tentativa em mais situações do que geralmente pensamos .

* A propósito, algumas linguagens, como C e C ++, permitem variáveis ​​não inicializadas e não possuem análise de atribuição definida para impedir a leitura delas. Como a leitura de memória não inicializada faz com que os programas se comportem de maneira não determinística e errática , geralmente é recomendável evitar a introdução de variáveis nesses idiomas sem fornecer um inicializador. Em linguagens com análise de atribuição definida, como C # e Java, o compilador evita a leitura de variáveis ​​não inicializadas e também o menor mal de inicializá-las com valores sem sentido que posteriormente podem ser mal interpretados como significativos.

Você pode fazer isso para que os caminhos de código em que a variável não está atribuída gerem uma exceção (ou retornem).

Se você planeja executar alguma ação (como registro em log) e relançar a exceção ou lançar outra exceção, e isso acontece em qualquer cláusula catch em que a variável não esteja atribuída, o compilador saberá que a variável foi atribuída:

int n;

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

Console.WriteLine(n);

Isso compila e pode muito bem ser uma escolha razoável. No entanto, em um aplicativo real, a menos que a exceção seja lançada apenas em situações em que nem faz sentido tentar recuperar * , você deve garantir que ainda está capturando e manipulando-o adequadamente em algum lugar .

(Você também não pode ler a variável em um bloco finalmente nessa situação, mas não parece que você deveria - afinal, os blocos finalmente sempre são executados essencialmente e, nesse caso, a variável nem sempre é atribuída .)

* Por exemplo, muitos aplicativos não possuem uma cláusula catch que lida com uma OutOfMemoryException porque qualquer coisa que eles possam fazer sobre isso pode ser pelo menos tão ruim quanto travar .

Talvez você realmente não quiser refatorar o código.

No seu exemplo, você apresenta firstVariablee secondVariabletenta blocos. Como eu disse, você pode defini-los antes dos blocos try nos quais eles são atribuídos, para que eles permaneçam no escopo posteriormente, e você pode satisfazer / enganar o compilador para permitir que você os leia, certificando-se de que eles sempre sejam atribuídos.

Mas o código que aparece após esses blocos depende, presumivelmente, deles terem sido atribuídos corretamente. Se for esse o caso, seu código deve refletir e garantir isso.

Primeiro, você pode (e deve) realmente lidar com o erro aí? Um dos motivos pelos quais o tratamento de exceções existe é facilitar o tratamento de erros onde eles podem ser tratados com eficiência , mesmo que isso não esteja próximo de onde eles ocorrem.

Se você realmente não consegue lidar com o erro na função que inicializou e usa essas variáveis, talvez o bloco try não deva estar nessa função, mas em algum lugar mais alto (ou seja, no código que chama essa função ou código que chama esse código). Apenas verifique se você não está capturando acidentalmente uma exceção lançada em outro lugar e assumindo erroneamente que ela foi lançada durante a inicialização firstVariablee secondVariable.

Outra abordagem é colocar o código que usa as variáveis ​​no bloco try. Isso geralmente é razoável. Novamente, se as mesmas exceções que você está capturando de seus inicializadores também puderem ser lançadas a partir do código ao redor, certifique-se de não negligenciar essa possibilidade ao manipulá-las.

(Suponho que você esteja inicializando as variáveis ​​com expressões mais complicadas do que as mostradas em seus exemplos, de modo que elas possam realmente gerar uma exceção e também que você não esteja planejando capturar todas as exceções possíveis , mas apenas para capturar quaisquer exceções específicas você pode antecipar e lidar de maneira significativa.É verdade que o mundo real nem sempre é tão bom e o código de produção às vezes faz isso , mas como seu objetivo aqui é lidar com erros que ocorrem durante a inicialização de duas variáveis ​​específicas, qualquer cláusula catch que você escrever para esse específico propósito deve ser específico para quaisquer erros que sejam.)

Uma terceira maneira é extrair o código que pode falhar e o try-catch que lida com ele, em seu próprio método. Isso é útil se você quiser lidar com os erros completamente primeiro e depois não se preocupar com a captura acidental de uma exceção que deve ser tratada em outro lugar.

Suponha, por exemplo, que você queira sair imediatamente do aplicativo após falha na atribuição de qualquer variável. (Obviamente, nem todo tratamento de exceção é para erros fatais; este é apenas um exemplo e pode ou não ser como você deseja que seu aplicativo reaja ao problema.) Você pode fazer algo assim:

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

Esse código retorna e desconstrói um ValueTuple com a sintaxe do C # 7.0 para retornar vários valores, mas se você ainda estiver em uma versão anterior do C #, ainda poderá usar esta técnica; por exemplo, você pode usar parâmetros ou retornar um objeto personalizado que fornece os dois valores . Além disso, se as duas variáveis ​​não estiverem realmente estreitamente relacionadas, provavelmente seria melhor ter dois métodos separados de qualquer maneira.

Especialmente se você tiver vários métodos como esse, considere centralizar seu código para notificar o usuário sobre erros fatais e sair. (Por exemplo, você pode escrever um Diemétodo com um messageparâmetro.) A throw new InvalidOperationException();linha nunca é realmente executada; portanto, você não precisa (nem deve) escrever uma cláusula catch para ela.

Além de encerrar quando ocorre um erro específico, às vezes você pode escrever um código parecido com esse se lançar uma exceção de outro tipo que envolve a exceção original . (Nessa situação, você não precisaria de uma segunda expressão de lançamento inacessível.)

Conclusão: o escopo é apenas parte da imagem.

Você pode obter o efeito de agrupar seu código com tratamento de erros sem refatoração (ou, se preferir, com quase nenhuma refatoração), apenas separando as declarações das variáveis ​​de suas atribuições. O compilador permite isso se você atender às regras de atribuição definidas do C # e declarar uma variável antes do bloco try deixa seu escopo maior claro. Mas refatorar ainda mais pode ser sua melhor opção.

Eliah Kagan
fonte
"quando você escreve código em um bloco try com cláusulas catch, está dizendo ao compilador e a qualquer leitor humano que ele deva ser tratado como se nem todos pudessem ser executados". O que o compilador se importa é que o controle possa alcançar instruções posteriores, mesmo que instruções anteriores gerem uma exceção. O compilador normalmente assume que, se uma instrução lança uma exceção, a próxima instrução não será executada, portanto, uma variável não atribuída não será lida. Adicionar um 'catch' permitirá que o controle alcance instruções posteriores - é o catch que importa, não se o código no bloco try é lançado.
Pete Kirkham