C#: throw; vs throw e;
Jakoś ostatnio często wpadają mi do rąk różne teksty o wyjątkach w
.Net. Z tego co da się zauważyć, to wyjątki, mimo swej prostej
koncepcji, nie są do końca rozumiane i dobrze używane. Na szczęście są
tacy ludzie jak
Krzysztof Cwalina, którzy pomagają biednym programistom. Bardzo przydatne informacje na temat wyjątków można znaleźć na jego
blogu. Ale nie o tym chciałem pisać...
Bardziej
zaawansowanych programistów C# może nie zaskoczę, ale na pewno znajdzie
się ktoś, kto nie do końca czuje różnicę (a może jej nie ma?).
Jeżeli
złapiemy wyjątek w blok try-catch i mimo wszystko nie jesteśmy w stanie
go do końca obsłużyć to powinniśmy go puścić dalej. Początkujący
programista, aby ponownie zgłosić dalej ten sam wyjątek (ang. rethrow
exception), zapewne napisze tak (zgodnie ze swoją intuicją):
private static void B()
{
try
{
C();
}
catch (Exception e)
{
throw e;
}
}CLR
w momencie, gdy zgłaszamy wyjątek pisząc
throw new Exception() zapisuje ślad stosu wywołań funkcji (ang. stack
trace) - można się do niego dostać za pomocą właściwości StackTrace.
Funkcja, w której wyjątek został zgłoszony jest jego źródłem. Niestety
ku zaskoczeniu wielu programistów (mojemu też, gdy się o tym
dowiedziałem)
throw e;, co prawda rzuca ten
sam wyjątek, który został złapany, ale niestety ślad stosu zostaje
nadpisany nowym śladem ze źródłem w miejscu tego wywołania :(. To
nieuntuicyjne zachowanie jest przyczyną późniejszych problemów podczas
debugowania ponieważ nie znane jest rzeczywiste źródło błędu. Dlatego
należy pamiętać aby
nigdy nie rzucać wyjątków w ten sposób!
Przykładowo rozważmy następujący program:
static void Main(string[] args)
{
int w = 1;
A();
}
private static void A()
{
int w = 2;
B();
}
private static void B()
{
try
{
int w = 3;
C();
}
catch (Exception e)
{
throw e;
}
}
private static void C()
{
int w = 4;
D();
}
private static void D()
{
int w = 5;
throw new Exception("The method or operation is not implemented.");
}W wyniku jego uruchomienia dostaniemy następującą informację:
Wyjątek nieobsłużony: System.Exception: The method or operation is not implemented.
w ConsoleApplication1.Program.B() w D:\ConsoleApplication1\ConsoleApplication1\Program.cs:wiersz 30
w ConsoleApplication1.Program.A() w D:\ConsoleApplication1\ConsoleApplication1\Program.cs:wiersz 18
w ConsoleApplication1.Program.Main(String[] args) w D:\ConsoleApplication1\ConsoleApplication1\Program.cs:wiersz 12
Rzeczywiście,
niepoprawne ponowne rzucenie wyjątku w metodzie B() skutkuje obcięciem
śladu stosu. Zatem jak sobie z tym poradzić? Do tego właśnie służy
użycie
throw; bez parametru. Jeżeli teraz
zmodyfikujemy odpowiednio metodę B() to sytuacja się odmieni.
private static void B()
{
try
{
C();
}
catch (Exception e)
{
throw;
}
}Wynik uruchomienia jest następujący:
Wyjątek nieobsłużony: System.Exception: The method or operation is not implemented.
w ConsoleApplication1.Program.D() w D:\ConsoleApplication1\ConsoleApplication1\Program.cs:wiersz 43
w ConsoleApplication1.Program.C() w D:\ConsoleApplication1\ConsoleApplication1\Program.cs:wiersz 37
w ConsoleApplication1.Program.B() w D:\ConsoleApplication1\ConsoleApplication1\Program.cs:wiersz 30
w ConsoleApplication1.Program.A() w D:\ConsoleApplication1\ConsoleApplication1\Program.cs:wiersz 18
w ConsoleApplication1.Program.Main(String[] args) w D:\ConsoleApplication1\ConsoleApplication1\Program.cs:wiersz 12
Widać
zatem, że ślad stosu nie został obcięty i łatwo odnaleźć prawdziwe
źródło błędu. Myślę, że nie trzeba nikogo zachęcać do takiego właśnie
obsługiwania wyjątków. To może nam tylko usprawnić pracę. Różnicę, o
której tu mowa, najlepiej widać na przykładzie kodu MSIL. Dla
pierwszego przypadku mamy:
L_000f: ldloc.1
L_0010: throwNatomiast dla drugiego:
L_000e: nop
L_000f: rethrowJak
łatwo zauważyć MSIL ma dwie osobne instrukcje. Jedna - throw - służy do
zgłaszania wyjątków a druga - rethrow - do ponownego zgłaszania
wyjątków. Twórcy C# skondensowali je jednak do jednego słowa
kluczowego, co czasami rodzi problemy.
Chciałbym jeszcze
zauważyć jedną bardzo ciekawą rzecz. Co prawda wywołanie
throw; zachowuje ślad stosu to jednak ciekawy jest
sposób w jaki to robi. Otóż z powyższych rozważań można by było
wywnioskować, że powyższe wywołanie zachwuje się tak jakby wyjątek w
ogóle nie został złapany. Otóż nie! Wyjątek ten został złapany a co
więcej ma to swoje odbicie w śladzie stosu. Tak naprawdę to sam ślad
stosu jest obcinany a zachowywana jest tylko jego pełna tekstowa
reprezentacja (sic!). Zatem to co widzieliśmy w powyższych przykładach
jest tylko zapisem śladu stosu. Tę sytuację najlepiej zobaczyć
uruchamiając dwie powyższe aplikacje w trybie debug.
Wyjątek po uruchomieniu pierwszej wersji:

Wyjątek po uruchomieniu drugiej wersji:

Na
załączonych obrazkach widać, że używając
throw; rzeczywiście zachowujemy ślad stosu, ale
gdy spojrzymy na okno rzeczywistego śladu stosu to okaże się, że kończy
się on na metodzie, która złapała i ponownie rzuciła ten sam wyjątek.
Zatem nie mamy możliwości pójścia w górę stosu i dotarcia do
rzeczywistego źródła wyjątku. Nie mamy też dostępu do żadnych
informacji kontekstowych takich jak zmienne i parametry tych wywołań,
które zostały ucięte.
Można się zastanawiać jak poprawnie obsługiwać wyjątki. Ja myślę, że:
- Przede wszystkim należy używać
throw; zamiast throw e; - Po
drugie jeżeli nie obsługujemy wyjątku to nie ma sensu stosować bloku
try-catch i ponownie rzucać tego samego wyjątku bez żadnej obsługi.
Trzymając się tych wytycznych debugowanie będzie przyjemniejsze.