Ich habe hier viel Zeug geschrieben. Falls du gerade keine Zeit oder Lust meine geistigen Ergüsse wirklich durchzulesen, dann spring doch einfach nach unten zur Zusammenfassung… :-)
So. Ich habe nun ewig nicht mehr gebloggt, weil unendlich viele Leute Software haben wollen. Aber genug der Vorrede, los gehts.
Ich habe immer noch mein kleines Persönlichkeitsproblem in Bezug auf Fehlerbehandlung in .NET und Exceptions. Ich habe ja hier Restriktiv vs. Robuste Entwicklung eine Umfangreiche Diskussion gestartet, in wie weit man Exceptions einsetzen könnte.
Fangen wir doch mal beim Urschleim an. Früher gab es Rückgabewerte. Die gibt es übrigens immer noch, denn auf Windows-API-Ebene gibt es keine Exceptions. Die Windows-APIs geben meistens einen int-Wert zurück, der hilft, die ganzen Windows-Fehlercodes auszuwerten, die man übrigens hier finden kann (Windows Fehlercodes). Es handelt sich hier um 16.000 der unterschiedlichsten Standard-Windowsfehler. “Fehler” 0 bedeutet meist, dass alles in Ordnung ist. Jeder API-Bereich hat dann noch seine ganz eigenen Fehlercodes, die speziell auf den jeweiligen Bereich zugeschnitten sind (hier zum Beispiel die Windows Netzwerksicherheits-Fehlermeldungen. die dazugehörigen Nummern befinden sich jeweils in den entsprechenden C++-Headerdateien… leider)
Die Rückgabewerte können ausgewertet werden. Und das ist auch schon ein relevanter Knackpunkt. Sie können ausgewertet werden, müssen aber nicht. Es folgt ein kleines Beispiel, welches Daten auf eine Chipkarte schreibt. Ohne error-Handling.
1: private void WriteWithoutErrorHandling()
2: {
3: IntPtr handleReader;
4: IntPtr handleCard;
5: uint dwDLLVersion = 0;
6: IntPtr cardReaderContext; // Context for interact with cardreaders
7:
8: WinSCardFunctions.SCardEstablishContext(0, IntPtr.Zero, IntPtr.Zero, out cardReaderContext);
9: WinSCardFunctions.MCardInitialize(cardReaderContext, "MyCardReader", out handleReader, out dwDLLVersion);
10: WinSCardFunctions.MCardConnect(handleReader, 1, 0, out handleCard);
11:
12: byte[] data = new byte[5] { 0x17, 0x1A, 0x17, 0x50, 0xFF };
13: int uiBytes2Write = data.Length;
14: WinSCardFunctions.MCardWriteMemory(handleCard, 0, 0, data, out uiBytes2Write);
15:
16: WinSCardFunctions.MCardDisconnect(handleCard, 0);
17: WinSCardFunctions.MCardShutdown(handleReader);
18: }
Sieht doch eigentlich ganz gut aus. Oben ein paar Variablen, danach schön in Reih und Glied ein paar API-Calls, dann die Daten auf die Chipkarte schreiben und noch zum Abschluss wieder ein paar API-Calls. Alle diese APIs liefern einen int-Wert zurück, der mich aber in diesem Beispiel in keinster Weise interessiert. Keiner dieser APIs wirft eine Exception. Sprich, meine Methode stürzt niemals ab… juhu..
Nun das gleiche Beispiel nochmal mit annähernd kompletter Fehlerbehandlung.
1: private void WriteNormal()
2: {
3: IntPtr handleReader;
4: IntPtr handleCard;
5: uint dwDLLVersion = 0;
6: IntPtr cardReaderContext; // Context for interact with cardreaders
7:
8: SmartCardErrorCode ec = WinSCardFunctions.SCardEstablishContext(0, IntPtr.Zero, IntPtr.Zero, out cardReaderContext);
9: if (ec != SmartCardErrorCode.None)
10: throw new COMException("Error while establishing context.", (int)ec);
11:
12: ec = WinSCardFunctions.MCardInitialize(cardReaderContext, "MyCardReader", out handleReader, out dwDLLVersion);
13: if (ec != SmartCardErrorCode.None)
14: throw new COMException("Error while initialize the card reader.", (int)ec);
15:
16: ec = WinSCardFunctions.MCardConnect(handleReader, 1, 0, out handleCard);
17: if (ec != SmartCardErrorCode.None)
18: {
19: WinSCardFunctions.MCardShutdown(handleReader);
20: throw new COMException("Error while connect to the card.", (int)ec);
21: }
22:
23: byte[] data = new byte[5] { 0x17, 0x1A, 0x17, 0x50, 0xFF };
24: int uiBytes2Write = data.Length;
25: ec = WinSCardFunctions.MCardWriteMemory(handleCard, 0, 0, data, out uiBytes2Write);
26: if (ec != SmartCardErrorCode.None)
27: {
28: WinSCardFunctions.MCardShutdown(handleReader);
29: throw new COMException("Error while writing data to the card. Byets written: " + uiBytes2Write.ToString(), (int)ec);
30: }
31:
32: WinSCardFunctions.MCardDisconnect(handleCard, 0);
33: WinSCardFunctions.MCardShutdown(handleReader);
34: }
Man sieht, dass sich der Code fast verdoppelt hat. Jeder API-Call (abgesehen von den letzten paar), sind mit einer Fehlerbehandlungsroutine abgesichert. Der Rückgabewert jeder API wird geprüft, und wenn irgendwas nicht stimmt, wird eine entsprechende Exception mit individuellem Text und der Fehlernummer geworfen. Man sieht an dieser Stelle schön, dass es sehr aufwendig sein kann, ordentliche Fehlerbehandlung zu betreiben. Ich glaube, bei API-Calls hat man oft nicht die Chance, sich um die entstandenen Probleme selbst zu kümmern. Man muss sie einfach nach oben reichen mittels einer Exception und oben muss man sich drum kümmern. Würde man hier z.B. einfach nur einen boolschen Rückgabewert machen, der im Fehlerfall einfach nur false zurückliefert, geht die Fehlerinformation verloren. Niemand weiß dann, welche Funktion eigentlich nicht funktioniert hat. Man weiß nur, “es geht nicht.” Und wir alle wissen ja, wie sehr wir von Fehlermeldungen der Art “Unbekannter Fehler” begeistert sind.
In einer “normalen” Methode ohne APIs sollte man nicht bei jeder Kleinigkeit eine Exception werfen, aber das ist wieder situationsabhängig und wie mein anderer oben genannter Blogeintrag bewiesen hat, auch eine Charakterfrage.
Im Folgenden wird das Prinzip “Deep Nested” beschrieben (Ja, ein oder zwei cool klingende englische Worte, um wesentlich professioneller zu klingen… ;-))
1: private void ShowDeepNested(int a, int b, int c, int d, int e)
2: {
3: if (a > 5)
4: {
5: RunMethodWitha(a);
6: if (b == 0)
7: {
8: RunMethodWithab(a, b);
9: if (c == -1)
10: {
11: RunMethodWithc(c);
12: if (d < -5)
13: {
14: RunMethodWithabcd(a, b, c, d);
15: if (e == int.MaxValue)
16: {
17: RunMethodWithabe(a, b, e + c);
18: }
19: }
20: }
21: }
22: }
23: }
24:
Es handelt sich um eine Methode mit 5 int-Parametern. Alle 5 müssen bestimmte Bedingungen erfüllen. Je nach Bedingungs-Konglomerat werden unterschiedliche Dinge ausgeführt bzw. errechnet (Bitte an dieser Stelle keine Aussagen wie: “Mhh, man is der blöd, da kann man doch super nen Strategy-Pattern oder irgendein anderes cooles Konstrukt anwenden, dann hat man solche Probleme nicht.. Und so komisch verschachtelte Methoden schreibt man doch eh nicht…”. Es geht hier um Beispiele, und in der echten Welt kann man nicht immer auf ner grünen Wiese anfangen und alles von Anfang an super strukturiert durchdenken und alle Ausnahmen von vorn herein berücksichtigen! Stichwort “Historisch gewachsen”)
Was man wohl auf Anhieb sieht, ist, dass es relativ unübersichtlich ist. Die zu starke Verschachtelung bedeutet, dass man an jeder Zeile wissen muss, welche Bedingung denn hier nun eigentlich gilt. Um so näher wir dem Höhepunkt der if-Verschachtelung kommen (in Zeile 17), desto “richtiger” scheint der Code zu werden, weil anscheinend immer mehr Bedingungen erfüllt sind.
Was man aber wohl erst auf dem zweiten Blick erkennt, ist, dass man die ganzen Negativ-Fälle eigentlich einfach nur umschifft hat. Denn was ist denn, wenn nun doch die erste Bedingung falsch ist? Oder die dritte? Dann gehen wir gekonnt über alles drüber und es passiert nix oder nur die Hälfte. Klar gibt es Fälle, wo das exakt so gewünscht ist, aber oft merkt man erst beim Negativtest, dass man da ja doch noch irgendwas machen muss…
Nun möchte ich eine weitere Vorgehensweise zeigen, die mit den coolen Worten “Early Out” bezeichnet wird.
1: private void ShowEarlyOut(int a, int b, int c, int d, int e)
2: {
3: if ((a > 5) == false)
4: {
5: return;
6: }
7: RunMethodWitha(a);
8: if ((b == 0) == false)
9: {
10: return;
11: }
12: RunMethodWithab(a, b);
13: if ((c == -1) == false)
14: {
15: return;
16: }
17: RunMethodWithc(c);
18: if ((d < -5) == false)
19: {
20: return;
21: }
22: RunMethodWithabcd(a, b, c, d);
23: if ((e == int.MaxValue) == false)
24: {
25: return;
26: }
27: RunMethodWithabe(a, b, e + c);
28: }
Man sieht wieder die gleiche Methode mit 5 int-Parametern. Diesmal ist es aber so, dass einfach die Bedingungen negiert werden, und man sofort den Fehlerfall behandelt. Wenn man über ein if hinweg kommt, so befindet man sich immer noch in seiner ganz normalen Methodenebene, und hat trotzdem seine Parameter geprüft. Dies sieht nicht nur übersichtlicher aus, es verdeutlicht auch sehr stark, was eigentlich passiert, wenn mal eine Bedingung nicht stimmt. Hier ist es viel einfacher, in einem Fehlerfall eine Variable vllt. zurückzusetzen o.ä. Man beachte aber auch in diesem Beispiel, dass die Fehlerinformation verloren geht.
Nun mal wieder zu Exceptions. Man kann sie nicht einfach ignorieren und die Behandlung kann sehr strukturiert erfolgen. Nachteile sind, dass Exceptions, wenn sie geworfen werden, sehr sehr langsam sind. Das Zusammenbauen des Stacktraces erfordert sehr viel Performance. In einer Schleife immer schön die ganze Zeit eine Exception werfen, kann die Ausführung von Millisekunden auf Minuten erhöhen. Dass man eine Exception behandeln muss und nicht ignorieren kann, kann aber auch ein Nachteil sein, weil in der echten Welt oft die Anforderung kommt, dass ein Kunde bei einer Stapelausführung nicht genervt den gesamten Prozess abbrechen möchte, wenn mal ein Wert nicht hingehauen hat.. Schauen wir uns dazu mal die nächsten Snippets an.
1: private void RunSzenario()
2: {
3: try
4: {
5: LoadDataInMemory();
6: CalculateSumOnData();
7: GroupDataInMemory();
8: CreateReportOnData();
9: SendReportToAllUser();
10: FreeTheMemory();
11: }
12: catch (Exception)
13: {
14: // Was hier? Loggen? Oder MessageBox.Show("Geht nicht");
15: }
16: }
Dieses kleine Szenario, was Daten lädt, etwas mit ihnen macht und dann die Daten wieder freigibt, wird in diesen 6 Methoden gekapselt. Diese Methode werfen unterschiedliche Exceptions. Ich habe einen try-Catch-Block drum gemacht, damit mir das ganze nicht gänzlich um die Ohren fliegt. Der Stacktrace der Exception ist für die Entwickler interessant, und die Message wahrscheinlich auch für den Entwickler, vlt. auch für den Enduser. Aber trotzdem könnte die Fehlerauswertung an dieser Stelle detaillierter sein. Wird eine Exception ausgelöst werden alle folgenden Methoden nicht aufgerufen. Das kann so gewollt sein. Manchmal entstehen aber auch Situationen, wo es Probleme bei der Ausführung gibt, selbst wenn man eine Aufräum-Methode in den Finally-Block tut.
Das folgende Beispiel muss niemand von euch lesen. Wichtig ist eigentlich nur mal die Zeilenanzahl. Ich habe mir jetzt mal ein paar Exceptions ausgedacht, die all diese Methoden werfen können, und ich hab versucht, alle ordnungsgemäß zu behandeln (hier nur mit dem Anzeigen einer “richtigen” Meldung für den User)…
1: private void RunSzenario()
2: {
3: try
4: {
5: LoadDataInMemory();
6: }
7: catch (AccessDeniedException)
8: {
9: MessageBox.Show("Speicherbereich geschützt.");
10: try
11: {
12: LogException();
13: }
14: catch (LogException)
15: {
16: MessageBox.Show("Beim Protokollieren eines Fehlers beim Laden ist ein Fehler aufgetreten.");
17: }
18: }
19: catch (DataNotFoundException dEx)
20: {
21: MessageBox.Show("Die Daten konnten nicht geladen werden. Grund: " + dEx.Message);
22: }
23: catch (Exception)
24: {
25: MessageBox.Show("Unbekannter Fehler beim Laden... Sorry");
26: }
27: try
28: {
29: CalculateSumOnData();
30: }
31: catch (SecurityException)
32: {
33: MessageBox.Show("Sie haben keine Berechtigung, Berechnungen auf diesen Daten auszuführen");
34: }
35: catch (SumIsTooBigException)
36: {
37: MessageBox.Show("Das geht aber so nich.. Die Summe is viel zu groß.");
38: }
39: catch (Exception)
40: {
41: MessageBox.Show("Unbekannter Fehler beim Summieren... Sorry");
42: }
43: try
44: {
45: GroupDataInMemory();
46: }
47: catch (MonsterGroupException mEx)
48: {
49: MessageBox.Show("Gruppierung ist aufgrund des folgenden Wertes nicht möglich: " + mEx.InvalidValue);
50: }
51: catch (Exception)
52: {
53: MessageBox.Show("Unbekannter Fehler beim gruppieren... Sorry");
54: }
55: try
56: {
57: CreateReportOnData();
58: }
59: catch (OutOfPaperException)
60: {
61: MessageBox.Show("Dieser Bericht würde ihre Kapazität an Papier übersteigen.");
62: }
63: catch (FileNotFoundException fEx)
64: {
65: MessageBox.Show("Die Berichtsvorlagedatei " + fEx.File + " konnte nicht gefunden werden.");
66: }
67: catch (Exception)
68: {
69: MessageBox.Show("Unbekannter Fehler beim Erstellen der Berichte... Sorry");
70: }
71: try
72: {
73: SendReportToAllUser();
74: }
75: catch (UsersAreAtHomeException)
76: {
77: MessageBox.Show("Keine Benutzer gefunden.");
78: }
79: catch (SendingNotSupportedException)
80: {
81: MessageBox.Show("Das Versenden von was acuh immer ist zur Zeit nicht erlaubt.");
82: }
83: catch (Exception)
84: {
85: MessageBox.Show("Unbekannter Fehler... Sorry");
86: }
87: finally
88: {
89: try
90: {
91: FreeTheMemory();
92: }
93: catch (MemoryNotFreeException)
94: {
95: MessageBox.Show("Der Speicher kann nun auch nciht mehr freigegeben werden");
96: }
97: catch (OutOfMemoryException)
98: {
99: MessageBox.Show("Der Speicher ist zu voll zum Freigeben");
100: }
101: catch (Exception)
102: {
103: MessageBox.Show("Unbekannter Fehler... Sorry");
104: }
105: }
106: }
Man beachte die 16 Zeilen vorher zu diesen 106 Zeilen. An dieser Stelle sei übrigens mal erwähnt, dass “gute” Software, bzw. sagen wir mal ausgereifte Software, generell MEHR Fehlerbehandlungscode beinhaltet als normalen Ausführungscode, da der Benutzer vor dem Rechner bekanntlich viel mehr Falsch als Richtig macht, und die Software ihn bei seiner täglichen Arbeit unterstützt, indem sie seine Fehler korrigiert oder ihn dezent auf den Fehler hinweist und ihm Hinweise zur Korrektur gibt (langer Satz). Hier sind nun aber auch keine Strukturen wie Vererbung von Exceptions und Aufgliedern in verschiedene Gruppen vorhanden. Exceptions können ja vom Fehlertext her selbst schon immer detaillierter werden, wenn man sie richtig einsetzt und selbst geschriebene Exception einer gut strukturierten Vererbungshierarchie unterlegt. In dem Beispiel is auch noch nicht mal ne Schleife drin… Das wäre ja sonst ein reines Chaos.
Die Vorteile liegen klar auf der Hand, man weiss zu jedem Zeitpunkt so gut wie exakt, was genau wer wo warum falsch gemacht hat. So, schaut man aber nun doch nochmal erneut hin, so sieht man in Zeile 12, dass eine Methode im Catch-Block ausgeführt wird, die aber nun wiederum eine Exception werfen kann. Die Methode in Zeile 91, die sich im finally-Block befindet, kann ebenfalls wieder Exceptions werfen, um die sich auch wieder gekümmert werden muss. Exceptions, die in einem catch-Block oder finally-Block auftreten, sind nicht automatisch abgesichert. Es können so Exceptions durchrutschen, und es entsteht ein großes geschachteltes WirrWarr von try- und catch-Blöcken. Weiterhin können in ungünstigen Konstellationen Exceptions sogar überschrieben werden, wenn man nicht explizit immer alle Exceptions beibehält und als InnerException mit weiterreicht.
So, und nun??? Ich stelle zum Thema Exceptions mal folgendes fest.
Grundsätzlich gilt die Devise, Exceptions vermeiden wo es geht (z.B. mit if(!File.Exists(…)) return; vorher einer FileNotFoundException aus dem Weg gehen).
Exceptions sollten nur dann gefangen werden, wenn sie auch behandelt werden können. Ansonsten werden sie einfach durchgelassen und jemand anders, der sie auch behandeln kann, kümmert sich drum. (Nur loggen ist z.B. keine Behandlung)
Selbst sollte nur in wirklichen Ausnahmefällen Exceptions geworfen werden, und nicht, weil man lange keine mehr geworfen hat. Und was genau “Ausnahmefälle” sind, ist wieder situations- bzw. sogar charakterabhängig…
Manchmal möchte man einfach auch mal ohne Exceptions arbeiten wie früher. Deswegen sind auch heutzutage Rückgabewerte nichts böses. Denkt man nur mal an die “TryParse”-Methoden der simplen Datentypen. Die funktionieren ohne Exception, was einfach auch mal sehr schön ist. Es muss ja nicht immer wegen jeder Kleinigkeit die ganze Welt untergehen.
Wenn man diese Grundlagen beherzigt, ist das Leben vllt. schon etwas strukturierter. Aber nun hat man wieder das Problem, dass man eigentlich immer mal wissen müsste, wann überhaupt welche Exception geworfen werden kann??? Nicht nur Methoden können Exceptions werfen, auch Operatoren können das. Oder Konvertierungen, oder auch Konstruktoren. Das wiederum bedeutet, dass ich meine Instanz vllt. in einem try-Block erzeugen sollte, was wiederum heißt, dass sie außerhalb dieses Blockes nicht zur Verfügung steht… Hierzu ein weiteres kleines Beispiel:
1: private double Devide(int a, int b)
2: {
3: try
4: {
5: return a / b;
6: }
7: catch (DivideByZeroException)
8: {
9: return double.NaN;
10: }
11: }
In diesem Beispiel kann eine DeviceByZeroException auftreten, aber wo genau kommt die denn eigentlich her? Ja, der /-Operator wirft diese Exception. aber wo steht denn das eigentlich? Und wie viele sonstige, nicht soo offensichtliche Exception sind in unseren alltäglichen Programmen versteckt? Exceptions entstehen immer besonders gern auf den Rechnern von Kunden. Zur Sicherheit kann man ja generell überall einen riesigen try-catch-Block drum machen, so ist man immer auf der sicheren Seite (Dieser Satz war zu einem Großteil Ironie! Einfach überall ein try-catch-Block sieht nicht danach aus, als hätte man sich Gedanken gemacht, vor allem nicht, wenn man nur mach “catch(Exception)”.).
Dann gibt es noch.. relativ neu, Code Contracts. Ein kurzer Blick auf folgendes Beispiel:
1: private double Devide(int a, int b)
2: {
3: Contract.Requires(b != 0);
4:
5: return a / b;
6: }
Das wirklich schöne an dieser Sache ist, dass ich mich als Entwickler nun nicht um diesen Fall kümmern muss (sofern sichergestellt ist, dass alle anderen Team-Mitglieder auch die Contracts nutzen und achten). Es sieht übersichtlich aus und die Methode wird nicht überschwemmt von Fehlerbehandlungscode. Das erfordert aber auch das Wissen darüber, was überhaupt alles schief gehen kann. Sprich, ich muss also Wissen, was ich tue. und zwar am besten Immer, in jeder Zeile!.
Zusammenfassung:
Fakten!!!
1. Fehlerbehandlung ist ein essentieller Teil einer jeden guten Software.
2. Im Allgemeinen ist die Fehlerbehandlung genauso aufwendig, wie die eigentlichen Prozesse im Workflow einer Software. Das merkt man aber auch erst, wenn man wirklich mal intensive und gut durchdachte sowie strukturierte Fehlerbehandlung betreibt.
3. Fehlerbehandlungscode übersteigt von der Menge her oft den eigentlichen Code, in dem der Fehler auftreten kann.
4. Keine Fehlerbehandlung zu betreiben ist schlecht!
5. Mit eines der größten Probleme bei der Fehlerbehandlung ist die Frage: “Ja und was mach ich im Fehlerfall???”
6. Viele Exceptions treten nur beim Kunden auf, nicht auf dem Entwicklungsrechner… Warum auch. Der Kunde glaubt dann immer, wir Entwickler bauen irgendwas ein wie: “if(Rechner == Kunde) throw new KundenException();”
7. Egal was eine Methode macht, ob sie eine Exception wirft oder im Fehlerfall z.B. einfach nur string.Empty zurückliefert. Es muss im Methodenkopf dokumentiert sein!
Früher gab's unsere Rückgabewerte. Heute gibt es Exceptions, was schon wesentlich besser ist, aber auch seine Probleme mit sich bringt. Sofern es möglich ist, denke ich, sollte man CodeContracts benutzen. Sie bieten meiner Meinung nach bisher das Beste “Preis/Leistung”-Verhältnis. Microsoft hat extra in .NET 4.0 die CodeContracts an sehr vielen Stellen nachgezogen. Leider heißt auch CodeContract nicht, dass wir nie wieder try-catch-Blöcke oder Exceptions brauchen.
Zum Thema Exceptions sei folgendes gesagt. Exceptions sollten sehr sparsam eingesetzt werden. Nicht bei jeder Kleinigkeit sofort eine Exception werfen. Exceptions sollten vermieden werden wo immer es geht und keines Falls im Standardablauf der Software notwendig sein (abgesehen von sehr wenigen Ausnahmen, wie z.B. ThreadAbortException). Exceptions sollten unbedingt gefangen werden, und zwar nur an den Stellen, wo sie auch behandelt werden können. Spätestens als qualifizierte Fehlermeldung in der GUI sollte eine Exception erscheinen und dem Benutzer eine Meldung mit vielen detaillierten Informationen liefern oder ihn nach einer Entscheidung fragen. Aber auch ein boolscher Rückgabewert ist kein Verbrechen…