Dlaczego w języku C # zmienne są zadeklarowane w bloku try w ograniczonym zakresie?

23

Chcę dodać obsługę błędów do:

var firstVariable = 1;
var secondVariable = firstVariable;

Poniższe informacje nie zostaną skompilowane:

try
{
   var firstVariable = 1;
}
catch {}

try
{
   var secondVariable = firstVariable;
}
catch {}

Dlaczego blok try catch musi wpływać na zakres zmiennych, podobnie jak inne bloki kodu? Odkładając na bok spójność, czy nie ma sensu pakować naszego kodu w obsługę błędów bez konieczności refaktoryzacji?

JᴀʏMᴇᴇ
źródło
14
A try.. catchjest specyficznym typem bloku kodu i jeśli chodzi o wszystkie bloki kodu, nie można zadeklarować zmiennej w jednym i używać tej samej zmiennej w innym ze względu na zakres.
Neil,
„jest określonym rodzajem bloku kodu”. Konkretnie w jaki sposób? Dzięki
JᴀʏMᴇᴇ
7
Chodzi mi o to, że wszystko między nawiasami klamrowymi to blok kodu. Widać to po instrukcji if i po instrukcji for, chociaż koncepcja jest taka sama. Zawartość jest w zakresie podwyższonym w stosunku do zakresu nadrzędnego. Jestem pewien, że byłoby to problematyczne, gdybyś po prostu użył nawiasów klamrowych {}bez próby.
Neil,
Wskazówka: pamiętaj, że korzystanie z (IDisposable) {} i tylko {} stosuje się podobnie. Gdy użyjesz użycia z IDisposable, automatycznie oczyści zasoby bez względu na sukces lub porażkę. Jest kilka wyjątków od tego, na przykład nie wszystkie klasy, których można oczekiwać, implementują IDisposable ...
Julia McGuigan
1
Dużo dyskusji na temat tego samego pytania na StackOverflow, tutaj: stackoverflow.com/questions/94977/...
Jon Schneider

Odpowiedzi:

90

Co jeśli twój kod to:

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

try
{
   var secondVariable = firstVariable;
}
catch {}

Teraz będziesz próbował użyć niezadeklarowanej zmiennej ( firstVariable), jeśli wywołane zostanie wywołanie metody.

Uwaga : powyższy przykład odpowiada konkretnie na pierwotne pytanie, które stwierdza „na bok spójność”. To pokazuje, że istnieją powody inne niż spójność. Ale jak pokazuje odpowiedź Piotra, istnieje również mocny argument konsekwentności, który z pewnością byłby bardzo ważnym czynnikiem przy podejmowaniu decyzji.

Ben Aaronson
źródło
Achh, właśnie tego szukałem. Wiedziałem, że niektóre funkcje językowe uniemożliwiają to, co sugerowałem, ale nie mogłem wymyślić żadnych scenariuszy. Dziękuję bardzo.
JᴀʏMᴇᴇ
1
„Teraz będziesz próbował użyć niezadeklarowanej zmiennej, jeśli wywołanie metody wywołuje”. Ponadto załóżmy, że można tego uniknąć, traktując zmienną tak, jakby została zadeklarowana, ale nie zainicjowana, przed kodem, który może wyrzucić. Wtedy nie byłby niezadeklarowany, ale nadal byłby potencjalnie nieprzypisany, a określona analiza przypisań uniemożliwiłaby odczytanie jego wartości (bez pośredniego przypisania, które mogłoby się zdarzyć).
Eliah Kagan
3
W przypadku języka statycznego, takiego jak C #, deklaracja jest istotna tylko w czasie kompilacji. Kompilator może łatwo przenieść deklarację wcześniej w tym zakresie. Ważniejszym faktem w czasie wykonywania jest to, że zmienna może nie zostać zainicjowana .
jpmc26,
3
Nie zgadzam się z tą odpowiedzią. C # ma już zasadę, że nie można odczytać niezainicjowanych zmiennych, z pewną świadomością przepływu danych. (Spróbuj zadeklarować zmienne w przypadkach switchai uzyskać do nich dostęp w innych.) Ta reguła może łatwo zastosować się tutaj i uniemożliwić kompilację tego kodu. Myślę, że odpowiedź Petera poniżej jest bardziej wiarygodna.
Sebastian Redl,
2
Istnieje różnica między niezadeklarowanym niezainicjowanym a C # śledzi je osobno. Jeśli pozwolono ci na użycie zmiennej poza blokiem, w którym została zadeklarowana, oznaczałoby to, że będziesz mógł przypisać ją do pierwszego catchbloku, a następnie na pewno zostanie przypisany do drugiego trybloku.
sick
64

Wiem, że Ben na to dobrze odpowiedział, ale chciałem zająć się kwestią spójności POV, którą wygodnie odsunięto na bok. Zakładając, że try/catchbloki nie wpłynęły na zasięg, to skończyłoby się to:

{
    // new scope here
}

try
{
   // Not new scope
}

I dla mnie to zderza się z zasadą najmniejszego zdziwienia (POLA), ponieważ teraz masz {i }pełnisz podwójną funkcję w zależności od kontekstu tego, co je poprzedziło.

Jedynym wyjściem z tego bałaganu jest wyznaczenie innego znacznika do wyznaczenia try/catchbloków. Który zaczyna dodawać zapach kodu. Do czasu, gdy będziesz mieć język bez scopów try/catch, byłby to taki bałagan, że lepiej byłoby, gdybyś miał wersję z lunetą.

Peter M.
źródło
Kolejna doskonała odpowiedź. I nigdy nie słyszałem o POLA, więc miłej lektury. Wielkie dzięki kolego.
JᴀʏMᴇᴇ
„Jedynym wyjściem z tego bałaganu jest wyznaczenie innego znacznika do wytyczenia try/ catchblokowania”. - masz na myśli try { { // scope } }:? :)
CompuChip,
@CompuChip, który miałby {}ciągnięcie podwójnego obowiązku jako zakresu, a nie tworzenie zakresu w zależności od kontekstu. try^ //no-scope ^byłby przykładem innego markera.
Leliel,
1
Moim zdaniem jest to o wiele bardziej fundamentalny powód i bliższy „prawdziwej” odpowiedzi.
Jack Aidley,
@JackAidley, zwłaszcza że możesz już pisać kod, w którym używasz zmiennej, która może nie zostać przypisana. Tak więc, chociaż odpowiedź Bena ma rację na temat tego, w jaki sposób jest to użyteczne zachowanie, nie uważam, że takie zachowanie istnieje. W odpowiedzi Bena zauważono, że OP mówi „na bok spójność”, ale konsekwencja jest absolutnie dobrym powodem! Wąski zakres ma wiele innych zalet.
Kat
21

Odkładając na bok spójność, czy nie ma sensu pakować naszego kodu w obsługę błędów bez konieczności refaktoryzacji?

Aby odpowiedzieć na to pytanie, konieczne jest przyjrzenie się nie tylko zakresowi zmiennej .

Nawet jeśli zmienna pozostanie w zakresie, nie zostanie definitywnie przypisana .

Deklaracja zmiennej w bloku try wyraża - dla kompilatora i dla czytelników - że ma ona znaczenie tylko w tym bloku. Wymusza to kompilator.

Jeśli chcesz, aby zmienna była w zasięgu po bloku try, możesz zadeklarować ją poza blokiem:

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

Wyraża to, że zmienna może mieć znaczenie poza blokiem try. Kompilator pozwoli na to.

Ale pokazuje także inny powód, dla którego zwykle nie byłoby użyteczne utrzymywanie zmiennych w zakresie po wprowadzeniu ich w bloku try. Kompilator C # wykonuje analizę przypisania określonego i zabrania odczytu wartości zmiennej, której nie udowodniono, że została podana wartość. Więc nadal nie możesz odczytać ze zmiennej.

Załóżmy, że próbuję odczytać ze zmiennej po bloku try:

Console.WriteLine(firstVariable);

To da błąd podczas kompilacji :

CS0165 Zastosowanie nieprzypisanej zmiennej lokalnej „firstVariable”

Zadzwoniłem Environment.Exit w bloku catch, tak ja wiem zmienna została przypisana przed wywołaniem Console.WriteLine. Ale kompilator nie wnioskuje o tym.

Dlaczego kompilator jest tak rygorystyczny?

Nie mogę nawet tego zrobić:

int n;

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

Console.WriteLine(n);

Jednym ze sposobów spojrzenia na to ograniczenie jest stwierdzenie, że analiza przypisania określonego w języku C # nie jest bardzo skomplikowana. Ale innym sposobem na to jest to, że kiedy piszesz kod w bloku try z klauzulami catch, mówisz zarówno kompilatorowi, jak i wszystkim ludzkim czytelnikom, że należy go traktować tak, jakby nie wszyscy byli w stanie uruchomić.

Aby zilustrować, co mam na myśli, wyobraź sobie, czy kompilator dopuścił powyższy kod, ale następnie dodałeś wywołanie w bloku try do funkcji , o której osobiście wiesz, że nie zgłasza wyjątku . Nie będąc w stanie zagwarantować, że wywołana funkcja nie wyrzuci an IOException, kompilator nie mógł wiedzieć, że nzostał przypisany, a następnie trzeba by było dokonać refaktoryzacji.

Oznacza to, że rezygnując z wysoce wyrafinowanej analizy w celu ustalenia, czy zmienna przypisana w bloku try z klauzulami catch została definitywnie przypisana później, kompilator pomaga uniknąć pisania kodu, który może później ulec uszkodzeniu. (W końcu złapanie wyjątku zwykle oznacza, że ​​uważasz, że ktoś może zostać rzucony).

Możesz upewnić się, że zmienna jest przypisana przez wszystkie ścieżki kodu.

Kod można skompilować, nadając zmiennej wartość przed blokiem try lub w bloku catch. W ten sposób nadal będzie inicjalizowany lub przypisany, nawet jeśli przypisanie w bloku try nie nastąpi. Na przykład:

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

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

Console.WriteLine(n);

Lub:

int n;

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

Console.WriteLine(n);

Te się kompilują. Ale najlepiej zrobić coś takiego, jeśli podana wartość domyślna ma sens * i zapewnia prawidłowe zachowanie.

Zauważ, że w tym drugim przypadku, w którym przypisujesz zmienną w bloku try i we wszystkich blokach catch, chociaż możesz odczytać zmienną po try-catch, nadal nie będziesz w stanie odczytać zmiennej wewnątrz dołączonego finallybloku , ponieważ wykonanie może pozostawić blok próbny w większej liczbie sytuacji, niż się często wydaje .

* Nawiasem mówiąc, niektóre języki, takie jak C i C ++, oba dopuszczają niezainicjowane zmienne i nie mają określonej analizy przypisań, aby zapobiec ich odczytaniu. Ponieważ odczyt niezainicjowanej pamięci powoduje, że programy zachowują się w sposób niedeterministyczny i nieregularny , generalnie zaleca się unikanie wprowadzania zmiennych w tych językach bez podawania inicjatora. W językach z definitywną analizą przypisań, takich jak C # i Java, kompilator chroni Cię przed czytaniem niezainicjowanych zmiennych, a także mniejszym złem inicjowania ich bezwartościowymi wartościami, które później można błędnie interpretować jako znaczące.

Możesz tak ustawić, aby ścieżki kodu, do których nie przypisano zmiennej, generowały wyjątek (lub zwracały).

Jeśli planujesz wykonać jakąś akcję (np. Rejestrację) i ponownie rzucić wyjątek lub zgłosić inny wyjątek, a dzieje się to we wszystkich klauzulach catch, w których zmienna nie jest przypisana, kompilator będzie wiedział, że zmienna została przypisana:

int n;

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

Console.WriteLine(n);

To się kompiluje i może być rozsądnym wyborem. Jednak w rzeczywistej aplikacji, chyba że wyjątek zostanie zgłoszony tylko w sytuacjach, w których próba odzyskania * nie ma sensu , powinieneś upewnić się, że gdzieś go wychwytujesz i odpowiednio go obsługuje .

(W tej sytuacji nie można również odczytać zmiennej w bloku na końcu, ale nie wydaje się, że powinieneś być w stanie - w końcu bloki zasadniczo zawsze działają, aw tym przypadku zmienna nie zawsze jest przypisana .)

* Na przykład wiele aplikacji nie ma klauzuli catch, która obsługuje wyjątek OutOfMemoryException, ponieważ wszystko, co mogłyby z tym zrobić, może być co najmniej tak samo złe, jak awaria .

Może naprawdę nie chcą byłaby kod.

W swoim przykładzie wprowadzasz firstVariablei secondVariablewypróbowujesz bloki. Jak już powiedziałem, możesz zdefiniować je przed blokami try, do których są przypisane, aby pozostały one w zasięgu, a także możesz zaspokoić / oszukać kompilator, umożliwiając czytanie z nich, upewniając się, że są one zawsze przypisane.

Ale kod pojawiający się po tych blokach prawdopodobnie zależy od ich prawidłowego przypisania. W takim przypadku Twój kod powinien to odzwierciedlić i zapewnić.

Po pierwsze, czy (i powinienem) rzeczywiście poradzić sobie z błędem? Jednym z powodów, dla których istnieje obsługa wyjątków, jest ułatwienie radzenia sobie z błędami, w których można je skutecznie obsłużyć , nawet jeśli nie jest to blisko miejsca ich wystąpienia.

Jeśli nie jesteś w stanie obsłużyć błędu w funkcji, która została zainicjowana i używa tych zmiennych, być może blok try nie powinien w ogóle znajdować się w tej funkcji, ale powinien być gdzieś wyżej (tj. W kodzie wywołującym tę funkcję lub w kodzie który wywołuje ten kod). Tylko upewnij się, że nie przypadkowo złapałeś wyjątek zgłoszony w innym miejscu i błędnie zakładając, że został zgłoszony podczas inicjowania firstVariablei secondVariable.

Innym podejściem jest umieszczenie kodu używającego zmiennych w bloku try. Jest to często rozsądne. Ponownie, jeśli te same wyjątki, które wychwytujesz z ich inicjatorów, mogą być również wyrzucone z otaczającego kodu, powinieneś upewnić się, że nie zaniedbujesz tej możliwości podczas ich obsługi.

(Zakładam, że inicjujesz zmienne wyrażeniami bardziej skomplikowanymi niż te pokazane w twoich przykładach, tak, że mogą one generować wyjątek, a także, że tak naprawdę nie planujesz wychwycić wszystkich możliwych wyjątków , ale po prostu złapać jakieś wyjątki wyjątkowe możesz przewidzieć i w znaczący sposób obsłużyć . To prawda, że rzeczywisty świat nie zawsze jest taki fajny, a kod produkcyjny czasami to robi , ale ponieważ Twoim celem jest tutaj obsługa błędów, które występują podczas inicjowania dwóch określonych zmiennych, wszelkie klauzule catch, które piszesz dla tego konkretnego cel powinien być specyficzny dla wszelkich błędów).

Trzecim sposobem jest wyodrębnienie kodu, który może zawieść, oraz try-catch, który go obsługuje, we własnej metodzie. Jest to przydatne, jeśli najpierw chcesz całkowicie zająć się błędami, a następnie nie martwić się przypadkowym wyłapaniem wyjątku, który zamiast tego powinien zostać rozwiązany w innym miejscu.

Załóżmy na przykład, że chcesz natychmiast zamknąć aplikację po niepowodzeniu przypisania którejkolwiek ze zmiennych. (Oczywiście nie każda obsługa wyjątków dotyczy błędów krytycznych; jest to tylko przykład i może, ale nie musi, sposób, w jaki aplikacja ma reagować na problem). Możesz to zrobić w ten sposób:

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

Ten kod zwraca i dekonstruuje ValueTuple ze składnią C # 7.0, aby zwrócić wiele wartości, ale jeśli nadal korzystasz z wcześniejszej wersji C #, nadal możesz użyć tej techniki; na przykład można użyć parametrów out lub zwrócić niestandardowy obiekt, który udostępnia obie wartości . Ponadto, jeśli te dwie zmienne nie są ściśle ze sobą powiązane, prawdopodobnie lepiej byłoby mieć dwie osobne metody.

Zwłaszcza jeśli masz wiele takich metod, powinieneś rozważyć scentralizowanie kodu w celu powiadamiania użytkownika o poważnych błędach i rezygnacji. (Na przykład, można napisać Diemetodę z messageparametrem.) Linia nie jest faktycznie wykonywana , więc nie trzeba (i nie powinien) napisać klauzulę catch dla niego.throw new InvalidOperationException();

Oprócz zamykania, gdy wystąpi konkretny błąd, możesz czasami napisać kod, który wygląda tak, jeśli zgłosisz wyjątek innego typu, który otacza oryginalny wyjątek . (W tej sytuacji, byś nie potrzebuje drugiego, nieosiągalny wyrażenie throw).

Wniosek: Zakres jest tylko częścią obrazu.

Możesz osiągnąć efekt owijania kodu z obsługą błędów bez refaktoryzacji (lub, jeśli wolisz, prawie bez refaktoryzacji), po prostu oddzielając deklaracje zmiennych od ich przypisań. Kompilator pozwala na to, jeśli spełniasz określone reguły przypisania w języku C #, a zadeklarowanie zmiennej przed blokiem try powoduje, że jej większy zakres jest jasny. Ale dalsze refaktoryzacja może być nadal najlepszą opcją.

Eliah Kagan
źródło
„kiedy piszesz kod w bloku try z klauzulami catch, mówisz zarówno kompilatorowi, jak i wszystkim ludzkim czytelnikom, że należy go traktować tak, jakby nie wszystkie były w stanie uruchomić”. Kompilatorowi zależy na tym, aby kontrola mogła sięgać do późniejszych instrukcji, nawet jeśli poprzednie instrukcje zgłaszają wyjątek. Kompilator zwykle zakłada, że ​​jeśli jedna instrukcja zgłosi wyjątek, następna instrukcja nie zostanie wykonana, więc nieprzypisana zmienna nie zostanie odczytana. Dodanie „catch” pozwoli kontroli dotrzeć do późniejszych instrukcji - liczy się catch, a nie to, czy wyrzuca kod w bloku try.
Pete Kirkham