Clean Code und die Kunst der Ausnahmebehandlung

Veröffentlicht: 2022-03-11

Ausnahmen sind so alt wie die Programmierung selbst. Damals, als die Programmierung in Hardware oder über Low-Level-Programmiersprachen erfolgte, wurden Ausnahmen verwendet, um den Ablauf des Programms zu ändern und Hardwarefehler zu vermeiden. Heute definiert Wikipedia Ausnahmen als:

anormale oder außergewöhnliche Bedingungen, die eine spezielle Verarbeitung erfordern – die häufig den normalen Ablauf der Programmausführung verändern …

Und der Umgang mit ihnen erfordert:

spezialisierte Programmiersprachenkonstrukte oder Computerhardwaremechanismen.

Daher erfordern Ausnahmen eine besondere Behandlung, und eine nicht behandelte Ausnahme kann zu unerwartetem Verhalten führen. Die Ergebnisse sind oft spektakulär. Im Jahr 1996 wurde der berühmte Raketenstartfehler der Ariane 5 einer unbehandelten Überlaufausnahme zugeschrieben. History's Worst Software Bugs enthält einige andere Fehler, die auf nicht behandelte oder falsch behandelte Ausnahmen zurückgeführt werden könnten.

Im Laufe der Zeit trugen diese Fehler und unzählige andere (die vielleicht nicht so dramatisch, aber dennoch katastrophal für die Beteiligten waren) zu dem Eindruck bei, dass Ausnahmen schlecht sind .

Aber Ausnahmen sind ein grundlegendes Element moderner Programmierung; sie existieren, um unsere Software besser zu machen. Anstatt Ausnahmen zu fürchten, sollten wir sie annehmen und lernen, davon zu profitieren. In diesem Artikel werden wir erörtern, wie Sie Ausnahmen elegant verwalten und sie verwenden können, um sauberen Code zu schreiben, der besser wartbar ist.

Ausnahmebehandlung: Es ist eine gute Sache

Mit dem Aufkommen der objektorientierten Programmierung (OOP) ist die Unterstützung von Ausnahmen zu einem entscheidenden Element moderner Programmiersprachen geworden. Heutzutage ist in die meisten Sprachen ein robustes Ausnahmebehandlungssystem eingebaut. Beispielsweise sieht Ruby das folgende typische Muster vor:

 begin do_something_that_might_not_work! rescue SpecificError => e do_some_specific_error_clean_up retry if some_condition_met? ensure this_will_always_be_executed end

Am vorherigen Code ist nichts falsch. Eine übermäßige Verwendung dieser Muster führt jedoch zu Codegerüchen und ist nicht unbedingt von Vorteil. Ebenso kann ein Missbrauch Ihrer Codebasis großen Schaden zufügen, sie spröde machen oder die Ursache von Fehlern verschleiern.

Das Stigma, das Ausnahmen umgibt, führt dazu, dass sich Programmierer oft ratlos fühlen. Es ist eine Tatsache des Lebens, dass Ausnahmen nicht vermieden werden können, aber uns wird oft beigebracht, dass sie schnell und entschlossen behandelt werden müssen. Wie wir sehen werden, ist dies nicht unbedingt wahr. Vielmehr sollten wir die Kunst erlernen, mit Ausnahmen elegant umzugehen und sie mit dem Rest unseres Codes harmonisch zu gestalten.

Im Folgenden finden Sie einige empfohlene Vorgehensweisen, die Ihnen helfen, Ausnahmen zu berücksichtigen und sie und ihre Fähigkeiten zu nutzen, um Ihren Code wartbar , erweiterbar und lesbar zu halten:

  • Wartbarkeit : Ermöglicht es uns, neue Fehler leicht zu finden und zu beheben, ohne befürchten zu müssen, dass die aktuelle Funktionalität unterbrochen, weitere Fehler eingeführt oder der Code aufgrund der zunehmenden Komplexität im Laufe der Zeit ganz aufgegeben werden muss.
  • Erweiterbarkeit : Ermöglicht es uns, unsere Codebasis einfach zu erweitern und neue oder geänderte Anforderungen zu implementieren, ohne die vorhandene Funktionalität zu beeinträchtigen. Erweiterbarkeit bietet Flexibilität und ermöglicht ein hohes Maß an Wiederverwendbarkeit für unsere Codebasis.
  • Lesbarkeit : Ermöglicht es uns, den Code leicht zu lesen und seinen Zweck zu entdecken, ohne zu viel Zeit mit dem Graben zu verbringen. Dies ist entscheidend für die effiziente Erkennung von Fehlern und ungetestetem Code.

Diese Elemente sind die Hauptfaktoren dessen, was wir Sauberkeit oder Qualität nennen könnten, was selbst kein direktes Maß ist, sondern stattdessen die kombinierte Wirkung der vorherigen Punkte ist, wie in diesem Comic gezeigt wird:

„WTFs/m“ von Thom Holwerda, OSNews

Nachdem dies gesagt ist, lassen Sie uns in diese Praktiken eintauchen und sehen, wie sich jede von ihnen auf diese drei Maßnahmen auswirkt.

Hinweis: Wir werden Beispiele aus Ruby präsentieren, aber alle hier gezeigten Konstrukte haben Entsprechungen in den gängigsten OOP-Sprachen.

Erstellen Sie immer Ihre eigene ApplicationError Hierarchie

Die meisten Sprachen verfügen über eine Vielzahl von Ausnahmeklassen, die wie jede andere OOP-Klasse in einer Vererbungshierarchie organisiert sind. Um die Lesbarkeit, Wartbarkeit und Erweiterbarkeit unseres Codes zu erhalten, ist es eine gute Idee, einen eigenen Unterbaum anwendungsspezifischer Ausnahmen zu erstellen, die die Basisausnahmeklasse erweitern. Etwas Zeit in die logische Strukturierung dieser Hierarchie zu investieren, kann äußerst vorteilhaft sein. Zum Beispiel:

 class ApplicationError < StandardError; end # Validation Errors class ValidationError < ApplicationError; end class RequiredFieldError < ValidationError; end class UniqueFieldError < ValidationError; end # HTTP 4XX Response Errors class ResponseError < ApplicationError; end class BadRequestError < ResponseError; end class UnauthorizedError < ResponseError; end # ... 

Beispiel für eine Anwendungsausnahmehierarchie: StandardError steht ganz oben. ApplicationError erbt davon. ValidationError und ResponseError erben beide davon. RequiredFieldError und UniqueFieldError erben von ValidationError, während BadRequestError und UnauthorizedError von ResponseError erben.

Ein erweiterbares, umfassendes Ausnahmepaket für unsere Anwendung erleichtert den Umgang mit diesen anwendungsspezifischen Situationen erheblich. Beispielsweise können wir entscheiden, welche Ausnahmen auf natürlichere Weise behandelt werden sollen. Dies erhöht nicht nur die Lesbarkeit unseres Codes, sondern erhöht auch die Wartbarkeit unserer Anwendungen und Bibliotheken (Gems).

Aus Sicht der Lesbarkeit ist es viel einfacher zu lesen:

 rescue ValidationError => e

Als zu lesen:

 rescue RequiredFieldError, UniqueFieldError, ... => e

Nehmen wir zum Beispiel aus Sicht der Wartbarkeit an, dass wir eine JSON-API implementieren und unseren eigenen ClientError mit mehreren Subtypen definiert haben, der verwendet wird, wenn ein Client eine fehlerhafte Anfrage sendet. Wenn einer dieser Fehler ausgelöst wird, sollte die Anwendung die JSON-Darstellung des Fehlers in ihrer Antwort rendern. Es ist einfacher, einen einzelnen Block zu beheben oder Logik hinzuzufügen, der ClientError s behandelt, anstatt jeden möglichen Clientfehler zu durchlaufen und denselben Handler-Code für jeden zu implementieren. In Bezug auf die Erweiterbarkeit können wir darauf vertrauen, dass wir, wenn wir später eine andere Art von Clientfehler implementieren müssen, bereits hier richtig behandelt werden.

Darüber hinaus hindert uns dies nicht daran, eine zusätzliche spezielle Behandlung für bestimmte Clientfehler früher im Aufrufstapel zu implementieren oder dasselbe Ausnahmeobjekt auf dem Weg zu ändern:

 # app/controller/pseudo_controller.rb def authenticate_user! fail AuthenticationError if token_invalid? || token_expired? User.find_by(authentication_token: token) rescue AuthenticationError => e report_suspicious_activity if token_invalid? raise e end def show authenticate_user! show_private_stuff!(params[:id]) rescue ClientError => e render_error(e) end

Wie Sie sehen können, hat uns das Auslösen dieser speziellen Ausnahme nicht daran gehindert, sie auf verschiedenen Ebenen zu handhaben, sie zu ändern, erneut auszulösen und dem Handler der übergeordneten Klasse zu erlauben, sie aufzulösen.

Hier sind zwei Dinge zu beachten:

  • Nicht alle Sprachen unterstützen das Auslösen von Ausnahmen innerhalb eines Ausnahmehandlers.
  • In den meisten Sprachen führt das Auslösen einer neuen Ausnahme innerhalb eines Handlers dazu, dass die ursprüngliche Ausnahme für immer verloren geht, daher ist es besser, dasselbe Ausnahmeobjekt (wie im obigen Beispiel) erneut auszulösen, um zu vermeiden, dass die ursprüngliche Ursache des Fehlers aus den Augen verloren wird Error. (Es sei denn, Sie tun dies absichtlich).

rescue Exception

Das heißt, versuchen Sie niemals, einen Catch-All-Handler für den Basisausnahmetyp zu implementieren. Das vollständige Retten oder Abfangen aller Ausnahmen ist in keiner Sprache eine gute Idee, sei es global auf Basisanwendungsebene oder in einer kleinen vergrabenen Methode, die nur einmal verwendet wird. Wir wollen Exception nicht retten, weil es verschleiert, was wirklich passiert ist, und sowohl die Wartbarkeit als auch die Erweiterbarkeit beeinträchtigt. Wir können eine Menge Zeit damit verschwenden, das eigentliche Problem zu debuggen, wenn es so einfach wie ein Syntaxfehler sein könnte:

 # main.rb def bad_example i_might_raise_exception! rescue Exception nah_i_will_always_be_here_for_you end # elsewhere.rb def i_might_raise_exception! retrun do_a_lot_of_work! end

Möglicherweise ist Ihnen der Fehler im vorherigen Beispiel aufgefallen; return ist falsch geschrieben. Obwohl moderne Editoren einen gewissen Schutz gegen diese spezielle Art von Syntaxfehlern bieten, zeigt dieses Beispiel, wie die rescue Exception unserem Code schadet. Zu keinem Zeitpunkt wird der eigentliche Typ der Ausnahme (in diesem Fall ein NoMethodError ) angesprochen, noch wird er jemals dem Entwickler offengelegt, was dazu führen kann, dass wir viel Zeit damit verschwenden, im Kreis zu laufen.

rescue Sie nie mehr Ausnahmen als nötig

Der vorherige Punkt ist ein spezieller Fall dieser Regel: Wir sollten immer darauf achten, unsere Exception-Handler nicht zu verallgemeinern. Die Gründe sind dieselben; Wann immer wir mehr Ausnahmen retten, als wir sollten, verstecken wir am Ende Teile der Anwendungslogik vor höheren Ebenen der Anwendung, ganz zu schweigen davon, dass die Fähigkeit des Entwicklers, die Ausnahme selbst zu behandeln, unterdrückt wird. Dies wirkt sich stark auf die Erweiterbarkeit und Wartbarkeit des Codes aus.

Wenn wir versuchen, verschiedene Ausnahmeuntertypen im selben Handler zu behandeln, führen wir fette Codeblöcke ein, die zu viele Verantwortlichkeiten haben. Wenn wir beispielsweise eine Bibliothek erstellen, die eine Remote-API verwendet, unterscheidet sich die Behandlung eines MethodNotAllowedError (HTTP 405) normalerweise von der Behandlung eines UnauthorizedError (HTTP 401), obwohl beide ResponseError s sind.

Wie wir sehen werden, gibt es oft einen anderen Teil der Anwendung, der besser geeignet wäre, bestimmte Ausnahmen trockener zu behandeln.

Definieren Sie also die einzelne Verantwortlichkeit Ihrer Klasse oder Methode und behandeln Sie das absolute Minimum an Ausnahmen, die diese Verantwortlichkeitsanforderung erfüllen . Wenn beispielsweise eine Methode dafür verantwortlich ist, Bestandsinformationen von einer Remote-API zu erhalten, sollte sie Ausnahmen behandeln, die sich aus dem Abrufen dieser Informationen ergeben, und die Behandlung der anderen Fehler einer anderen Methode überlassen, die speziell für diese Verantwortlichkeiten entwickelt wurde:

 def get_info begin response = HTTP.get(STOCKS_URL + "#{@symbol}/info") fail AuthenticationError if response.code == 401 fail StockNotFoundError, @symbol if response.code == 404 return JSON.parse response.body rescue JSON::ParserError retry end end

Hier haben wir den Vertrag für diese Methode definiert, um uns nur die Informationen über den Bestand zu geben. Es verarbeitet endpunktspezifische Fehler , z. B. eine unvollständige oder fehlerhafte JSON-Antwort. Es behandelt nicht den Fall, wenn die Authentifizierung fehlschlägt oder abläuft oder wenn der Bestand nicht vorhanden ist. Diese liegen in der Verantwortung einer anderen Person und werden ausdrücklich an den Call-Stack weitergegeben, wo es einen besseren Ort geben sollte, um diese Fehler trocken zu behandeln.

Widerstehen Sie dem Drang, Ausnahmen sofort zu behandeln

Dies ist die Ergänzung zum letzten Punkt. Eine Ausnahme kann an jedem Punkt in der Aufrufliste und an jedem Punkt in der Klassenhierarchie behandelt werden, daher kann es verwirrend sein, genau zu wissen, wo sie behandelt werden soll. Um dieses Rätsel zu lösen, entscheiden sich viele Entwickler dafür, jede Ausnahme sofort zu behandeln, wenn sie auftritt, aber wenn Sie Zeit investieren, um dies zu durchdenken, wird dies normalerweise dazu führen, dass Sie einen geeigneteren Ort finden, um bestimmte Ausnahmen zu behandeln.

Ein häufiges Muster, das wir in Rails-Anwendungen sehen (insbesondere in solchen, die nur JSON-APIs verfügbar machen), ist die folgende Controller-Methode:

 # app/controllers/client_controller.rb def create @client = Client.new(params[:client]) if @client.save render json: @client else render json: @client.errors end end

(Beachten Sie, dass dies zwar technisch gesehen kein Ausnahmebehandler ist, aber funktional denselben Zweck erfüllt, da @client.save nur falsch zurückgibt, wenn es auf eine Ausnahme stößt.)

In diesem Fall ist das Wiederholen des gleichen Fehlerbehandlers in jeder Controller-Aktion jedoch das Gegenteil von DRY und beeinträchtigt die Wartbarkeit und Erweiterbarkeit. Stattdessen können wir uns die besondere Natur der Ausnahmeweitergabe zunutze machen und sie nur einmal in der übergeordneten Controller-Klasse ApplicationController behandeln:

 # app/controllers/client_controller.rb def create @client = Client.create!(params[:client]) render json: @client end
 # app/controller/application_controller.rb rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity def render_unprocessable_entity(e) render \ json: { errors: e.record.errors }, status: 422 end

Auf diese Weise können wir sicherstellen, dass alle ActiveRecord::RecordInvalid -Fehler ordnungsgemäß und DRY-ly an einem Ort auf der Ebene des Basis- ApplicationController behandelt werden. Dies gibt uns die Freiheit, mit ihnen herumzuspielen, wenn wir bestimmte Fälle auf der unteren Ebene behandeln wollen, oder sie einfach anmutig verbreiten zu lassen.

Nicht alle Ausnahmen müssen behandelt werden

Bei der Entwicklung eines Gems oder einer Bibliothek versuchen viele Entwickler, die Funktionalität zu kapseln und zu verhindern, dass sich Ausnahmen aus der Bibliothek ausbreiten. Aber manchmal ist es nicht offensichtlich, wie eine Ausnahme behandelt werden soll, bis die spezifische Anwendung implementiert ist.

Nehmen wir ActiveRecord als Beispiel für die ideale Lösung. Die Bibliothek bietet Entwicklern zwei Ansätze zur Vollständigkeit. Die Methode save behandelt Ausnahmen, ohne sie weiterzugeben, indem sie einfach false , während save! löst eine Ausnahme aus, wenn es fehlschlägt. Dies gibt Entwicklern die Möglichkeit, bestimmte Fehlerfälle anders zu behandeln oder einfach jeden Fehler allgemein zu behandeln.

Aber was ist, wenn Sie nicht die Zeit oder die Ressourcen haben, um eine so vollständige Implementierung bereitzustellen? In diesem Fall ist es bei Unsicherheiten am besten , die Ausnahme offenzulegen und in die Wildnis zu entlassen.

Hier ist der Grund: Wir arbeiten fast die ganze Zeit mit sich ändernden Anforderungen, und die Entscheidung, dass eine Ausnahme immer auf eine bestimmte Weise gehandhabt wird, könnte unserer Implementierung tatsächlich schaden, die Erweiterbarkeit und Wartbarkeit beeinträchtigen und möglicherweise eine enorme technische Schuld hinzufügen, insbesondere bei der Entwicklung Bibliotheken.

Nehmen Sie das frühere Beispiel eines Aktien-API-Verbrauchers, der Aktienkurse abruft. Wir haben uns dafür entschieden, die unvollständige und fehlerhafte Antwort sofort zu bearbeiten, und wir haben uns dafür entschieden, dieselbe Anfrage erneut zu versuchen, bis wir eine gültige Antwort erhalten haben. Später können sich die Anforderungen jedoch ändern, sodass wir auf gespeicherte historische Bestandsdaten zurückgreifen müssen, anstatt die Anfrage erneut zu versuchen.

An diesem Punkt sind wir gezwungen, die Bibliothek selbst zu ändern und zu aktualisieren, wie diese Ausnahme behandelt wird, da die abhängigen Projekte diese Ausnahme nicht behandeln. (Wie könnten sie? Es war ihnen noch nie zuvor ausgesetzt.) Wir müssen auch die Eigentümer von Projekten informieren, die auf unsere Bibliothek angewiesen sind. Dies könnte zu einem Albtraum werden, wenn es viele solcher Projekte gibt, da sie wahrscheinlich auf der Annahme aufgebaut wurden, dass dieser Fehler auf eine bestimmte Weise behandelt wird.

Jetzt können wir sehen, wohin wir mit dem Abhängigkeitsmanagement gehen. Die Aussichten sind nicht gut. Diese Situation kommt ziemlich häufig vor, und meistens beeinträchtigt sie die Nützlichkeit, Erweiterbarkeit und Flexibilität der Bibliothek.

Hier ist also das Fazit: Wenn unklar ist, wie eine Ausnahme behandelt werden soll, lassen Sie sie ordnungsgemäß verbreiten . Es gibt viele Fälle, in denen ein klarer Ort vorhanden ist, um die Ausnahme intern zu behandeln, aber es gibt viele andere Fälle, in denen es besser ist, die Ausnahme offen zu legen. Bevor Sie sich also für die Behandlung der Ausnahme entscheiden, denken Sie noch einmal darüber nach. Eine gute Faustregel besteht darin, nur dann auf der Behandlung von Ausnahmen zu bestehen , wenn Sie direkt mit dem Endbenutzer interagieren.

Folgen Sie der Konvention

Die Implementierung von Ruby und noch mehr von Rails folgt einigen Namenskonventionen, wie z. B. der Unterscheidung zwischen method_names und method_names! mit einem Knall." In Ruby zeigt der Knall an, dass die Methode das Objekt ändert, das sie aufgerufen hat, und in Rails bedeutet es, dass die Methode eine Ausnahme auslöst, wenn sie das erwartete Verhalten nicht ausführt. Versuchen Sie, die gleiche Konvention zu respektieren, insbesondere wenn Sie Ihre Bibliothek als Open-Source-Bibliothek verwenden möchten.

Wenn wir eine neue method! Bei einem Knall in einer Rails-Anwendung müssen wir diese Konventionen berücksichtigen. Es gibt nichts, was uns dazu zwingt, eine Ausnahme auszulösen, wenn diese Methode fehlschlägt, aber indem sie von der Konvention abweicht, kann diese Methode Programmierer in die Irre führen, zu glauben, dass ihnen die Möglichkeit gegeben wird, Ausnahmen selbst zu behandeln, obwohl sie dies in Wirklichkeit nicht tun werden.

Eine andere Ruby-Konvention, die Jim Weirich zugeschrieben wird, besteht darin, fail zu verwenden, um einen Methodenfehler anzuzeigen, und raise nur zu verwenden, wenn Sie die Ausnahme erneut auslösen.

Abgesehen davon verwende ich in Ruby fast immer das Schlüsselwort fail anstelle des Schlüsselworts raise , da ich Ausnahmen verwende, um Fehler anzuzeigen. Fail und Raise sind Synonyme, daher gibt es keinen Unterschied, außer dass Fail deutlicher kommuniziert, dass die Methode fehlgeschlagen ist. Das einzige Mal, dass ich raise verwende, ist, wenn ich eine Ausnahme abfange und sie erneut auslöse, denn hier scheitere ich nicht, sondern löse explizit und absichtlich eine Ausnahme aus. Dies ist ein stilistisches Problem, dem ich folge, aber ich bezweifle, dass viele andere Leute dies tun.

Viele andere Sprachgemeinschaften haben Konventionen wie diese zur Behandlung von Ausnahmen übernommen, und das Ignorieren dieser Konventionen wird die Lesbarkeit und Wartbarkeit unseres Codes beeinträchtigen.

Logger.log(alles)

Diese Vorgehensweise gilt natürlich nicht nur für Ausnahmen, aber wenn etwas immer protokolliert werden sollte, dann ist es eine Ausnahme.

Die Protokollierung ist extrem wichtig (wichtig genug für Ruby, um einen Logger mit seiner Standardversion auszuliefern). Es ist das Tagebuch unserer Anwendungen, und noch wichtiger als aufzuzeichnen, wie unsere Anwendungen erfolgreich sind, ist zu protokollieren, wie und wann sie scheitern.

Es gibt keinen Mangel an Protokollierungsbibliotheken oder protokollbasierten Diensten und Entwurfsmustern. Es ist wichtig, unsere Ausnahmen im Auge zu behalten, damit wir überprüfen können, was passiert ist, und untersuchen können, ob etwas nicht richtig aussieht. Korrekte Log-Meldungen können Entwickler direkt auf die Ursache eines Problems hinweisen, wodurch sie unermessliche Zeit sparen.

Dieses Vertrauen in einen sauberen Code

Eine saubere Ausnahmebehandlung wird Ihre Codequalität zum Mond schicken!
Twittern

Ausnahmen sind ein grundlegender Bestandteil jeder Programmiersprache. Sie sind besonders und extrem mächtig, und wir müssen ihre Macht nutzen, um die Qualität unseres Codes zu verbessern, anstatt uns mit ihnen zu erschöpfen.

In diesem Artikel haben wir uns mit einigen bewährten Verfahren zur Strukturierung unserer Ausnahmebäume befasst und wie es für die Lesbarkeit und Qualität von Vorteil sein kann, sie logisch zu strukturieren. Wir haben uns verschiedene Ansätze zur Behandlung von Ausnahmen angesehen, entweder an einem Ort oder auf mehreren Ebenen.

Wir haben gesehen, dass es schlecht ist, sie alle zu fangen, und dass es in Ordnung ist, sie herumschweben und sprudeln zu lassen.

Wir haben uns angesehen, wo Ausnahmen trocken behandelt werden können, und haben festgestellt, dass wir nicht verpflichtet sind, sie zu behandeln, wenn oder wo sie zum ersten Mal auftreten.

Wir haben besprochen, wann genau es eine gute Idee ist, mit ihnen umzugehen, wann es eine schlechte Idee ist und warum es im Zweifelsfall eine gute Idee ist, sie sich ausbreiten zu lassen.

Abschließend haben wir andere Punkte besprochen, die dazu beitragen können, den Nutzen von Ausnahmen zu maximieren, z. B. das Befolgen von Konventionen und das Protokollieren von allem.

Mit diesen grundlegenden Richtlinien können wir uns beim Umgang mit Fehlerfällen in unserem Code viel wohler und sicherer fühlen und unsere Ausnahmen wirklich außergewöhnlich machen!

Besonderer Dank geht an Avdi Grimm und seinen großartigen Vortrag Exceptional Ruby, der bei der Erstellung dieses Artikels sehr geholfen hat.

Siehe auch: Tipps und Best Practices für Ruby-Entwickler