Python-Ausnahmen gelten jetzt als Anti-Pattern

Was sind Ausnahmen? Aus dem Namen geht hervor, dass sie auftreten, wenn im Programm eine Ausnahme auftritt. Sie fragen sich vielleicht, warum Ausnahmen ein Anti-Muster sind und in welcher Beziehung sie zur Eingabe stehen. Ich habe versucht, es herauszufinden , und jetzt möchte ich das mit Ihnen besprechen, harazhiteli.

Ausnahmeprobleme


Es ist schwer, Fehler in dem zu finden, mit denen Sie jeden Tag konfrontiert sind. Gewohnheit und Blindheit verwandeln Fehler in Merkmale, aber versuchen wir, Ausnahmen offen zu betrachten.

Ausnahmen sind schwer zu erkennen


Es gibt zwei Arten von Ausnahmen: "explizit" wird durch Aufrufen von " raise direkt in dem Code erstellt, den Sie gerade lesen. "Versteckt" sind in den verwendeten Funktionen, Klassen, Methoden versteckt.

Das Problem ist, dass die "versteckten" Ausnahmen wirklich schwer zu bemerken sind. Lassen Sie mich Ihnen ein Beispiel für eine reine Funktion zeigen:

 def divide(first: float, second: float) -> float: return first / second 

Die Funktion teilt einfach eine Zahl durch eine andere und gibt einen float . Die Typen werden überprüft und Sie können Folgendes ausführen:

 result = divide(1, 0) print('x / y = ', result) 

Hast du es bemerkt? Tatsächlich wird die Ausführung des Programms niemals den print erreichen, da das Teilen von 1 durch 0 eine unmögliche Operation ist und einen ZeroDivisionError . Ja, ein solcher Code ist typsicher, kann aber trotzdem nicht verwendet werden.

Um ein potenzielles Problem auch in einem so einfachen und lesbaren Code zu bemerken, braucht man Erfahrung. Alles in Python kann aufhören, mit verschiedenen Arten von Ausnahmen zu arbeiten: Division, Funktionsaufrufe, int , str , Generatoren, Iteratoren in Schleifen, Zugriff auf Attribute oder Schlüssel. Sogar raise something() kann einen Absturz verursachen. Außerdem erwähne ich nicht einmal Eingabe- und Ausgabeoperationen. Und geprüfte Ausnahmen werden in naher Zukunft nicht mehr unterstützt .

Die Wiederherstellung des normalen Verhaltens ist nicht möglich


Aber genau für einen solchen Fall haben wir Ausnahmen. Lassen Sie uns einfach ZeroDivisionError und der Code wird typsicher.

 def divide(first: float, second: float) -> float: try: return first / second except ZeroDivisionError: return 0.0 

Jetzt ist alles in Ordnung. Aber warum geben wir 0 zurück? Warum nicht 1 oder None ? Natürlich ist es in den meisten Fällen fast so schlecht (wenn nicht sogar noch schlimmer), None , aber Sie müssen sich dennoch auf die Geschäftslogik und die Optionen verlassen, um die Funktion verwenden zu können.

Was genau teilen wir? Beliebige Zahlen, bestimmte Einheiten oder Geld? Nicht jede Option ist leicht vorherzusehen und wiederherzustellen. Es kann sich herausstellen, dass Sie bei der nächsten Verwendung einer Funktion eine andere Wiederherstellungslogik benötigen.

Die traurige Schlussfolgerung: Die Lösung für jedes Problem ist individuell, abhängig vom spezifischen Anwendungskontext.

Es gibt keine Silberkugel, die sich ein für alle Mal mit ZeroDivisionError . Und wir sprechen nicht über die Möglichkeit komplexer E / A mit wiederholten Anforderungen und Zeitüberschreitungen.

Vielleicht ist es nicht notwendig, Ausnahmen genau dort zu behandeln, wo sie auftreten? Vielleicht werfen Sie es einfach in den Code-Ausführungsprozess - jemand wird es später herausfinden. Und dann sind wir gezwungen, zum aktuellen Stand der Dinge zurückzukehren.

Ausführungsprozess unklar


Hoffen wir, dass jemand anderes die Ausnahme abfängt und sie möglicherweise behandelt. Beispielsweise kann das System den Benutzer auffordern, den eingegebenen Wert zu ändern, da er nicht durch 0 geteilt werden kann. Die divide sollte nicht explizit für die Behebung des Fehlers verantwortlich sein.

In diesem Fall müssen Sie überprüfen, wo wir die Ausnahme abgefangen haben. Wie kann man übrigens genau bestimmen, wo es verarbeitet wird? Ist es möglich, an die richtige Stelle im Code zu gelangen? Es stellt sich heraus, dass es unmöglich ist .

Es ist nicht möglich zu bestimmen, welche Codezeile ausgeführt wird, nachdem die Ausnahme ausgelöst wurde. Verschiedene Arten von Ausnahmen können mit unterschiedlichen except , und einige Ausnahmen können ignoriert werden . Sie können zusätzliche Ausnahmen in anderen Modulen auslösen, die früher ausgeführt werden und im Allgemeinen die gesamte Logik beschädigen.

Angenommen, eine Anwendung enthält zwei unabhängige Threads: einen regulären Thread, der von oben nach unten ausgeführt wird, und einen Ausnahmethread, der nach Belieben ausgeführt wird. Wie kann man diesen Code lesen und verstehen?

Nur bei eingeschaltetem Debugger im Modus "Alle Ausnahmen abfangen".


Ausnahmen wie das berüchtigte goto reißen die Programmstruktur auf.

Ausnahmen sind nicht exklusiv


Schauen wir uns ein anderes Beispiel an: den üblichen Remote-HTTP-API-Zugangscode:

 import requests def fetch_user_profile(user_id: int) -> 'UserProfile': """Fetches UserProfile dict from foreign API.""" response = requests.get('/api/users/{0}'.format(user_id)) response.raise_for_status() return response.json() 

In diesem Beispiel kann buchstäblich alles schief gehen. Hier ist eine unvollständige Liste möglicher Fehler:

  • Das Netzwerk ist möglicherweise nicht verfügbar und die Anforderung wird überhaupt nicht ausgeführt.
  • Der Server funktioniert möglicherweise nicht.
  • Der Server ist möglicherweise zu ausgelastet. Es tritt eine Zeitüberschreitung auf.
  • Der Server erfordert möglicherweise eine Authentifizierung.
  • Eine API verfügt möglicherweise nicht über eine solche URL.
  • Ein nicht existierender Benutzer kann übertragen werden.
  • Möglicherweise nicht genug Rechte.
  • Der Server kann aufgrund eines internen Fehlers während der Verarbeitung Ihrer Anfrage abstürzen
  • Der Server gibt möglicherweise eine ungültige oder beschädigte Antwort zurück.
  • Der Server gibt möglicherweise ungültigen JSON zurück, der nicht analysiert werden kann.

Die Liste geht weiter und weiter, so viele potenzielle Probleme liegen im Code der unglücklichen drei Zeilen. Wir können sagen, dass es im Allgemeinen nur durch einen glücklichen Zufall funktioniert und mit einer Ausnahme viel wahrscheinlicher fällt.

Wie können Sie sich schützen?


Nachdem wir sichergestellt haben, dass Ausnahmen den Code schädigen können, wollen wir herausfinden, wie Sie sie entfernen können. Um ausnahmslos Code zu schreiben, gibt es verschiedene Muster.

  • Überall schreiben except Exception: pass . Sackgasse. Tu das nicht.
  • None . Zu böse. Infolgedessen müssen Sie entweder fast jede Zeile mit beginnen, if something is not None: und die gesamte Logik geht hinter dem Müll der Bereinigungsprüfungen verloren, oder Sie leiden die ganze Zeit unter TypeError . Keine gute Wahl.
  • Schreiben Sie Klassen für spezielle Anwendungsfälle. Zum Beispiel die Basisklasse User mit Unterklassen für Fehler wie UserNotFound und MissingUser . Dieser Ansatz kann in bestimmten Situationen verwendet werden, z. B. in AnonymousUser in Django. Das Umschließen aller möglichen Fehler in Klassen ist jedoch unrealistisch. Es wird zu viel Arbeit erfordern und das Domänenmodell wird unvorstellbar komplex.
  • Verwenden Sie Container, um die resultierende Variable oder den Fehlerwert in einen Wrapper zu verpacken, und arbeiten Sie weiter mit dem Containerwert. Aus diesem Grund haben wir das Projekt @dry-python/return . Diese Funktionen geben also etwas Sinnvolles, Typisiertes und Sicheres zurück.

Kehren wir zum Divisionsbeispiel zurück, das im Fehlerfall 0 zurückgibt. Können wir explizit angeben, dass die Funktion ohne Rückgabe eines bestimmten numerischen Werts nicht erfolgreich war?

 from returns.result import Result, Success, Failure def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success(first / second) except ZeroDivisionError as exc: return Failure(exc) 

Wir schließen die Werte in einen von zwei Wrappern ein: Success oder Failure . Diese Klassen werden von der Result Basisklasse geerbt. Arten von gepackten Werten können in der Anmerkung mit der zurückgegebenen Funktion angegeben werden, z. B. Result[float, ZeroDivisionError] gibt entweder Success[float] oder Failure[ZeroDivisionError] .

Was gibt uns das? Weitere Ausnahmen sind keine Ausnahme, aber erwartete Probleme . Das Umschließen einer Ausnahme in Failure löst außerdem ein zweites Problem: die Komplexität der Identifizierung potenzieller Ausnahmen.

 1 + divide(1, 0) # => mypy error: Unsupported operand types for + ("int" and "Result[float, ZeroDivisionError]") 

Jetzt sind sie leicht zu erkennen. Wenn im Code Result angezeigt wird, löst die Funktion möglicherweise eine Ausnahme aus. Und Sie kennen seinen Typ sogar im Voraus.

Darüber hinaus ist die Bibliothek vollständig typisiert und mit PEP561 kompatibel . Das heißt, mypy warnt Sie, wenn Sie versuchen, etwas zurückzugeben, das nicht dem deklarierten Typ entspricht.

 from returns.result import Result, Success, Failure def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success('Done') # => error: incompatible type "str"; expected "float" except ZeroDivisionError as exc: return Failure(0) # => error: incompatible type "int"; expected "ZeroDivisionError" 

Wie arbeite ich mit Containern?


Es gibt zwei Methoden :

  • map für Funktionen, die normale Werte zurückgeben;
  • bind für Funktionen, die andere Container zurückgeben.

 Success(4).bind(lambda number: Success(number / 2)) # => Success(2) Success(4).map(lambda number: number + 1) # => Success(5) 

Das Schöne ist, dass dieser Code Sie vor erfolglosen Skripten .bind , da .bind und .map für Container mit Failure nicht ausgeführt werden:

 Failure(4).bind(lambda number: Success(number / 2)) # => Failure(4) Failure(4).map(lambda number: number / 2) # => Failure(4) 

Jetzt können Sie sich nur noch auf den richtigen Ausführungsprozess konzentrieren und sicherstellen, dass der falsche Status das Programm nicht an einem unerwarteten Ort beschädigt. Und es besteht immer die Möglichkeit, den falschen Zustand festzustellen, zu korrigieren und zum beabsichtigten Pfad des Prozesses zurückzukehren.

 Failure(4).rescue(lambda number: Success(number + 1)) # => Success(5) Failure(4).fix(lambda number: number / 2) # => Success(2) 

In unserem Ansatz werden „alle Probleme einzeln gelöst“ und „der Ausführungsprozess ist jetzt transparent“. Viel Spaß beim Programmieren auf Schienen!

Aber wie kann man Werte aus Containern erweitern?


Wenn Sie mit Funktionen arbeiten, die nichts über Container wissen, benötigen Sie die Werte selbst. Dann können Sie die .unwrap() oder .value_or() verwenden:

 Success(1).unwrap() # => 1 Success(0).value_or(None) # => 0 Failure(0).value_or(None) # => None Failure(1).unwrap() # => Raises UnwrapFailedError() 

Warten Sie, wir mussten die Ausnahmen .unwrap() , und jetzt stellt sich heraus, dass alle .unwrap() -Aufrufe zu einer anderen Ausnahme führen können?

Wie man nicht an UnwrapFailedErrors denkt?


Ok, mal sehen, wie man mit den neuen Ausnahmen lebt. Betrachten Sie dieses Beispiel: Sie müssen Benutzereingaben überprüfen und zwei Modelle in der Datenbank erstellen. Jeder Schritt kann mit einer Ausnahme enden, weshalb alle Methoden in Result :

 from returns.result import Result, Success, Failure class CreateAccountAndUser(object): """Creates new Account-User pair.""" # TODO: we need to create a pipeline of these methods somehow... def _validate_user( self, username: str, email: str, ) -> Result['UserSchema', str]: """Returns an UserSchema for valid input, otherwise a Failure.""" def _create_account( self, user_schema: 'UserSchema', ) -> Result['Account', str]: """Creates an Account for valid UserSchema's. Or returns a Failure.""" def _create_user( self, account: 'Account', ) -> Result['User', str]: """Create an User instance. If user already exists returns Failure.""" 

Erstens müssen Sie die Werte in Ihrer eigenen Geschäftslogik überhaupt nicht erweitern:

 class CreateAccountAndUser(object): """Creates new Account-User pair.""" def __call__(self, username: str, email: str) -> Result['User', str]: """Can return a Success(user) or Failure(str_reason).""" return self._validate_user( username, email, ).bind( self._create_account, ).bind( self._create_user, ) # ... 

Alles wird ohne Probleme funktionieren, es werden keine Ausnahmen .unwrap() , da .unwrap() nicht verwendet wird. Aber ist es einfach, solchen Code zu lesen? Nein. Und was ist die Alternative? @pipeline :

 from result.functions import pipeline class CreateAccountAndUser(object): """Creates new Account-User pair.""" @pipeline def __call__(self, username: str, email: str) -> Result['User', str]: """Can return a Success(user) or Failure(str_reason).""" user_schema = self._validate_user(username, email).unwrap() account = self._create_account(user_schema).unwrap() return self._create_user(account) # ... 

Jetzt ist dieser Code gut gelesen. So arbeiten .unwrap() und @pipeline zusammen: Wenn eine .unwrap() -Methode fehlschlägt und Failure[str] , wird sie vom @pipeline Dekorateur @pipeline und gibt Failure[str] als resultierenden Wert zurück. Auf diese Weise schlage ich vor, alle Ausnahmen aus dem Code zu entfernen und ihn wirklich sicher und typisiert zu machen.

Wickeln Sie alles zusammen


Nun wenden wir die neuen Tools beispielsweise mit einer Anfrage an die HTTP-API an. Denken Sie daran, dass jede Zeile eine Ausnahme auslösen kann? Und es gibt keine Möglichkeit, sie dazu zu bringen, den Container mit Result . Sie können den @safe-Dekorator jedoch verwenden , um unsichere Funktionen zu verpacken und sicher zu machen. Im Folgenden finden Sie zwei Codeoptionen, die dasselbe tun:

 from returns.functions import safe @safe def divide(first: float, second: float) -> float: return first / second # is the same as: def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success(first / second) except ZeroDivisionError as exc: return Failure(exc) 

Die erste mit @safe ist einfacher und besser zu lesen.

Das letzte, was Sie im API-Anforderungsbeispiel tun müssen, ist das Hinzufügen des @safe Dekorators. Das Ergebnis ist der folgende Code:

 import requests from returns.functions import pipeline, safe from returns.result import Result class FetchUserProfile(object): """Single responsibility callable object that fetches user profile.""" #: You can later use dependency injection to replace `requests` #: with any other http library (or even a custom service). _http = requests @pipeline def __call__(self, user_id: int) -> Result['UserProfile', Exception]: """Fetches UserProfile dict from foreign API.""" response = self._make_request(user_id).unwrap() return self._parse_json(response) @safe def _make_request(self, user_id: int) -> requests.Response: response = self._http.get('/api/users/{0}'.format(user_id)) response.raise_for_status() return response @safe def _parse_json(self, response: requests.Response) -> 'UserProfile': return response.json() 

So fassen Sie zusammen, wie Sie Ausnahmen beseitigen und den Code sichern können :

  • Verwenden Sie den @safe Wrapper für alle Methoden, die eine Ausnahme @safe können. Der Rückgabetyp der Funktion wird in Result[OldReturnType, Exception] .
  • Verwenden Sie Result als Container, um Werte und Fehler in eine einfache Abstraktion zu übertragen.
  • Verwenden Sie .unwrap() , um den Wert aus dem Container zu erweitern.
  • Verwenden Sie @pipeline , um das .unwrap @pipeline .unwrap vereinfachen.

Durch die Einhaltung dieser Regeln können wir genau das Gleiche tun - nur sicher und gut lesbar. Alle Probleme mit Ausnahmen wurden behoben:

  • "Ausnahmen sind schwer zu erkennen . " Jetzt werden sie in einen typisierten Result eingewickelt, wodurch sie vollständig transparent sind.
  • "Die Wiederherstellung eines normalen Verhaltens ist unmöglich . " Jetzt können Sie den Wiederherstellungsprozess sicher an den Anrufer delegieren. Für einen solchen Fall gibt es .fix() und .rescue() .
  • "Die Reihenfolge der Ausführung ist unklar . " Jetzt sind sie eins mit dem üblichen Geschäftsfluss. Von Anfang bis Ende.
  • "Ausnahmen sind keine Ausnahme . " Wir wissen! Und wir erwarten, dass etwas schief geht und zu allem bereit ist.

Anwendungsfälle und Einschränkungen


Offensichtlich können Sie diesen Ansatz nicht in Ihrem gesamten Code verwenden. Es ist für die meisten alltäglichen Situationen zu sicher und mit anderen Bibliotheken oder Frameworks nicht kompatibel. Sie müssen jedoch die wichtigsten Teile Ihrer Geschäftslogik genau so schreiben, wie ich es gezeigt habe, um den ordnungsgemäßen Betrieb Ihres Systems sicherzustellen und den zukünftigen Support zu erleichtern.

Bringt Sie das Thema zum Nachdenken oder scheint es sogar heilig zu sein? Kommen Sie am 5. April nach Moskau Python Conf ++ , wir werden diskutieren! Neben mir wird Artyom Malyshev, der Gründer des Dry-Python-Projekts und Kernentwickler von Django Channels, dort sein. Er wird mehr über Dry Python und Geschäftslogik sprechen .

Source: https://habr.com/ru/post/de445234/


All Articles