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)
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')
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))
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))
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))
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()
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."""
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
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."""
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 .