Funktionsstil Ausnahmebehandlung

24

Mir wurde gesagt, dass man in der funktionalen Programmierung keine Ausnahmen werfen und / oder beobachten soll. Stattdessen sollte eine fehlerhafte Berechnung als unterer Wert gewertet werden. In Python (oder anderen Sprachen, die die funktionale Programmierung nicht vollständig unterstützen) kann man zurückgeben None(oder eine andere Alternative, die als unterster Wert behandelt wird, obwohl Nonesie der Definition nicht genau entspricht), wenn etwas schief geht, um "rein zu bleiben", aber zu tun man muss also zuerst einen Fehler beobachten, d.h.

def fn(*args):
    try:
        ... do something
    except SomeException:
        return None

Verstößt dies gegen die Reinheit? Und wenn ja, heißt das, dass es unmöglich ist, Fehler nur in Python zu behandeln?

Aktualisieren

In seinem Kommentar erinnerte mich Eric Lippert an einen anderen Weg, um Ausnahmen in FP zu behandeln. Obwohl ich das in Python noch nie in der Praxis gesehen habe, habe ich vor einem Jahr, als ich FP studierte, damit gespielt. Hier gibt jede optional-dekorierte Funktion OptionalWerte für normale Ausgaben sowie für eine angegebene Liste von Ausnahmen zurück , die leer sein können (nicht spezifizierte Ausnahmen können die Ausführung immer noch beenden). CarryErstellt eine verzögerte Auswertung, bei der jeder Schritt (verzögerter Funktionsaufruf) entweder eine nicht leere OptionalAusgabe des vorherigen Schritts erhält und diese einfach weiterleitet oder sich auf andere Weise selbst bewertet, indem er eine neue weiterleitet Optional. Am Ende ist der Endwert entweder normal oder Empty. Hier ist der try/exceptBlock hinter einem Dekorator verborgen, sodass die angegebenen Ausnahmen als Teil der Rückgabetypsignatur betrachtet werden können.

class Empty:
    def __repr__(self):
        return "Empty"


class Optional:
    def __init__(self, value=Empty):
        self._value = value

    @property
    def value(self):
        return Empty if self.isempty else self._value

    @property
    def isempty(self):
        return isinstance(self._value, BaseException) or self._value is Empty

    def __bool__(self):
        raise TypeError("Optional has no boolean value")


def optional(*exception_types):
    def build_wrapper(func):
        def wrapper(*args, **kwargs):
            try:
                return Optional(func(*args, **kwargs))
            except exception_types as e:
                return Optional(e)
        wrapper.__isoptional__ = True
        return wrapper
    return build_wrapper


class Carry:
    """
    >>> from functools import partial
    >>> @optional(ArithmeticError)
    ... def rdiv(a, b):
    ...     return b // a
    >>> (Carry() >> (rdiv, 0) >> (rdiv, 0) >> partial(rdiv, 1))(1)
    1
    >>> (Carry() >> (rdiv, 0) >> (rdiv, 1))(1)
    1
    >>> (Carry() >> rdiv >> rdiv)(0, 1) is Empty
    True
    """
    def __init__(self, steps=None):
        self._steps = tuple(steps) if steps is not None else ()

    def _add_step(self, step):
        fn, *step_args = step if isinstance(step, Sequence) else (step, )
        return type(self)(steps=self._steps + ((fn, step_args), ))

    def __rshift__(self, step) -> "Carry":
        return self._add_step(step)

    def _evaluate(self, *args) -> Optional:
        def caller(carried: Optional, step):
            fn, step_args = step
            return fn(*(*step_args, *args)) if carried.isempty else carried
        return reduce(caller, self._steps, Optional())

    def __call__(self, *args):
        return self._evaluate(*args).value
Eli Korvigo
quelle
1
Ihre Frage wurde bereits beantwortet, also nur ein Kommentar: Verstehen Sie, warum es bei der funktionalen Programmierung verpönt ist, wenn Ihre Funktion eine Ausnahme auslöst? Es ist keine willkürliche Laune :)
Andres F.
6
Es gibt eine andere Alternative zum Zurückgeben eines Werts, der den Fehler angibt. Denken Sie daran, dass die Ausnahmebehandlung ein Kontrollfluss ist und wir über Funktionsmechanismen zur Anpassung der Kontrollflüsse verfügen. Sie können die Ausnahmebehandlung in funktionalen Sprachen emulieren, indem Sie Ihre Methode so schreiben, dass sie zwei Funktionen übernimmt: die Fortsetzung des Erfolgs und die Fortsetzung des Fehlers . Das Letzte, was Ihre Funktion tut, ist, entweder die Erfolgsfortsetzung, die das "Ergebnis" übergibt, oder die Fehlerfortsetzung, die die "Ausnahme" übergibt, aufzurufen. Der Nachteil ist, dass Sie Ihr Programm von innen nach außen schreiben müssen.
Eric Lippert
3
Nein, wie wäre der Stand in diesem Fall? Es gibt mehrere Probleme, aber hier sind einige: 1- Es gibt einen möglichen Fluss, der nicht im Typ codiert ist, dh Sie können nicht wissen, ob eine Funktion eine Ausnahme auslöst, indem Sie sich nur den Typ ansehen (es sei denn, Sie haben nur geprüft) Ausnahmen natürlich, aber ich kenne keine Sprache, die nur sie hat). Sie arbeiten effektiv "außerhalb" des Typsystems, 2-Funktions-Programmierer bemühen sich, wann immer möglich "Gesamt" -Funktionen zu schreiben, dh Funktionen, die für jeden Eingang einen Wert zurückgeben (außer bei Nichtbeendigung). Ausnahmen wirken dem entgegen.
Andres F.
3
3- Wenn Sie mit Gesamtfunktionen arbeiten, können Sie sie mit anderen Funktionen zusammenstellen und in Funktionen höherer Ordnung verwenden, ohne sich Gedanken über Fehlerergebnisse machen zu müssen, die "nicht im Typ codiert" sind, dh Ausnahmen.
Andres F.
1
@EliKorvigo Das Setzen einer Variablen führt per Definition zu einem Punkt. Der gesamte Zweck von Variablen ist, dass sie den Status halten. Das hat nichts mit Ausnahmen zu tun. Wenn Sie den Rückgabewert einer Funktion beobachten und den Wert einer Variablen basierend auf der Beobachtung festlegen, wird der Status in die Auswertung eingefügt, nicht wahr?
User253751

Antworten:

20

Klären wir zunächst einige Missverständnisse auf. Es gibt keinen "unteren Wert". Der unterste Typ ist als ein Typ definiert, der ein Untertyp jedes anderen Typs in der Sprache ist. Hieraus kann man (zumindest in jedem interessanten Typensystem) beweisen, dass der unterste Typ keine Werte hat - er ist leer . Es gibt also keinen Bodenwert.

Warum ist der Bodentyp nützlich? Nun, da wir wissen, dass es leer ist, lassen Sie uns ein paar Rückschlüsse auf das Programmverhalten ziehen. Wenn wir zum Beispiel die Funktion haben:

def do_thing(a: int) -> Bottom: ...

Wir wissen, dass do_thingdas niemals zurückkehren kann, da es einen Wert vom Typ zurückgeben müsste Bottom. Es gibt also nur zwei Möglichkeiten:

  1. do_thing hört nicht auf
  2. do_thing löst eine Ausnahme aus (in Sprachen mit einem Ausnahmemechanismus)

Beachten Sie, dass ich einen Typ erstellt habe, Bottomder in der Python-Sprache nicht vorhanden ist. Noneist eine falsche Bezeichnung; Es ist tatsächlich der Einheitenwert , der einzige Wert des Einheitentyps , der NoneTypein Python aufgerufen wird type(None).

Ein weiteres Missverständnis ist, dass funktionale Sprachen keine Ausnahme haben. Das ist auch nicht wahr. SML hat zum Beispiel einen sehr schönen Ausnahmemechanismus. Ausnahmen werden in SML jedoch viel sparsamer verwendet als in z. B. Python. Wie Sie gesagt haben, besteht die übliche Möglichkeit, einen Fehler in funktionalen Sprachen anzuzeigen, darin, einen OptionTyp zurückzugeben. Zum Beispiel würden wir eine sichere Teilungsfunktion wie folgt erstellen:

def safe_div(num: int, den: int) -> Option[int]:
  return Some(num/den) if den != 0 else None

Leider ist dies kein praktikabler Ansatz, da Python eigentlich keine Summentypen hat. Sie könnten zurückkehren Noneals ein Arme-Leute-Option Typen , um anzuzeigen , Ausfall, aber das ist wirklich besser nicht als Rückkehr Null. Es gibt keine Typensicherheit.

Daher würde ich raten, in diesem Fall die Konventionen der Sprache zu befolgen. Python verwendet Ausnahmen idiomatisch, um den Kontrollfluss zu handhaben (was schlechtes Design ist, IMO, aber dennoch Standard). Wenn Sie also nicht nur mit selbst geschriebenem Code arbeiten, empfehle ich, die Standardpraxis zu befolgen. Ob dies "rein" ist oder nicht, spielt keine Rolle.

Gartenkopf
quelle
Mit "unterster Wert" meinte ich eigentlich den "untersten Typ", deshalb habe ich geschrieben, Noneder der Definition nicht entsprach. Trotzdem danke, dass du mich korrigiert hast. Denken Sie nicht, dass es mit Pythons Prinzipien in Ordnung ist, eine Ausnahme nur zu verwenden, um die Ausführung vollständig zu stoppen oder einen optionalen Wert zurückzugeben? Ich meine, warum ist es schlecht, Ausnahmen für eine komplizierte Kontrolle zu unterbinden?
Eli Korvigo
@EliKorvigo Das ist mehr oder weniger was ich gesagt habe, oder? Ausnahmen sind idiomatische Python.
Gardenhead
1
Zum Beispiel rate ich meinen Studenten davon ab, try/except/finallywie eine andere Alternative zu verwenden if/else, dh try: var = expession1; except ...: var = expression 2; except ...: var = expression 3...obwohl dies in jeder imperativen Sprache üblich ist (übrigens rate ich auch dringend davon ab, if/elseBlöcke dafür zu verwenden). Meinst du, dass ich unvernünftig bin und solche Muster zulassen sollte, da "das ist Python"?
Eli Korvigo
@ EliKorvigo Ich stimme Ihnen im Allgemeinen zu (übrigens, Sie sind Professor?). try... catch...sollte nicht für den Kontrollfluss verwendet werden. Aus irgendeinem Grund hat sich die Python-Community dazu entschlossen, Dinge zu tun. Zum Beispiel würde die safe_divFunktion , die ich oben geschrieben habe, normalerweise geschrieben try: result = num / div: except ArithmeticError: result = None. Wenn Sie ihnen also allgemeine Prinzipien des Software-Engineerings beibringen, sollten Sie davon definitiv abraten. if ... else...ist auch ein Codegeruch, aber das ist zu lang, um hier darauf einzugehen.
Gardenhead
2
Es gibt einen "Bottom-Wert" (beispielsweise für die Semantik von Haskell), der mit dem Bottom-Typ wenig zu tun hat. Das ist also nicht wirklich ein Missverständnis des OP, nur über eine andere Sache zu sprechen.
Ben
11

Warum untersuchen wir nicht, wie eine reine Funktion aussieht, da in den letzten Tagen so viel Interesse an Reinheit bestand?

Eine reine Funktion:

  • Ist referenziell transparent; Das heißt, für eine bestimmte Eingabe wird immer dieselbe Ausgabe erstellt.

  • Erzeugt keine Nebenwirkungen; Es ändert weder die Eingänge noch die Ausgänge oder irgendetwas anderes in seiner externen Umgebung. Es wird nur ein Rückgabewert erzeugt.

Also frag dich. Tut Ihre Funktion etwas anderes als eine Eingabe zu akzeptieren und eine Ausgabe zurückzugeben?

Robert Harvey
quelle
3
Es ist also egal, wie hässlich es geschrieben ist, solange es sich funktional verhält.
Eli Korvigo
8
Richtig, wenn Sie nur an Reinheit interessiert sind. Es könnte natürlich andere Dinge geben, die zu berücksichtigen sind.
Robert Harvey
3
Um die Devils Advocate zu spielen, würde ich argumentieren, dass das Auslösen einer Ausnahme nur eine Form der Ausgabe ist und Funktionen, die werfen, rein sind. Ist das ein Problem?
Bergi
1
@Bergi Ich weiß nicht, dass du Devil's Advocate spielst, denn genau das impliziert diese Antwort :) Das Problem ist, dass es neben der Reinheit noch andere Überlegungen gibt . Wenn Sie ungeprüfte Ausnahmen zulassen (die per Definition nicht Teil der Funktionssignatur sind), wird der Rückgabetyp jeder Funktion effektiv T + { Exception }(wobei Tes sich um den explizit deklarierten Rückgabetyp handelt), was problematisch ist. Sie können nicht wissen, ob eine Funktion eine Ausnahme auslöst, ohne sich den Quellcode anzusehen, was das Schreiben von Funktionen höherer Ordnung ebenfalls problematisch macht.
Andres F.
2
@Begri während des Werfens ist möglicherweise rein, IMO mit Ausnahmen macht den Rückgabetyp jeder Funktion mehr als nur kompliziert. Es schädigt die Zusammensetzbarkeit von Funktionen. Überlegen Sie, map : (A->B)->List A ->List Bwo A->BFehler auftreten können. Wenn wir erlauben, dass f eine Ausnahme map f L auslöst, wird etwas vom Typ zurückgegeben Exception + List<B>. Wenn wir stattdessen zulassen, dass ein optionalStiltyp zurückgegeben map f Lwird, wird stattdessen List <Optional <B >> `zurückgegeben. Diese zweite Option fühlt sich für mich funktionaler an.
Michael Anderson
7

Die Haskell-Semantik verwendet einen "unteren Wert", um die Bedeutung des Haskell-Codes zu analysieren. Es ist nicht etwas, das Sie wirklich direkt beim Programmieren von Haskell verwenden, und das Zurückkehren Noneist überhaupt nicht dasselbe.

Der unterste Wert ist der Wert, den die Haskell-Semantik jeder Berechnung zuweist, die nicht normal bewertet werden kann. Eine solche Möglichkeit, die eine Haskell-Berechnung bietet, besteht darin, eine Ausnahme auszulösen! Wenn Sie also versuchen, diesen Stil in Python zu verwenden, sollten Sie eigentlich wie gewohnt Ausnahmen auslösen.

Die Haskell-Semantik verwendet den unteren Wert, da Haskell faul ist. Sie können "Werte" manipulieren, die von Berechnungen zurückgegeben werden, die noch nicht ausgeführt wurden. Sie können sie an Funktionen übergeben, in Datenstrukturen speichern usw. Eine solche nicht ausgewertete Berechnung kann eine Ausnahme oder eine Endlosschleife auslösen. Wenn wir den Wert jedoch nie tatsächlich untersuchen müssen, wird die Berechnung dies niemals tunFühren Sie den Befehl aus, und stellen Sie fest, dass der Fehler aufgetreten ist. Unser Gesamtprogramm schafft es möglicherweise, eine genau definierte Aufgabe zu erledigen und zu beenden. Ohne zu erklären, was Haskell-Code bedeutet, indem das genaue Betriebsverhalten des Programms zur Laufzeit angegeben wird, deklarieren wir stattdessen, dass solche fehlerhaften Berechnungen den unteren Wert erzeugen, und erklären, wie sich dieser Wert verhält. Grundsätzlich gilt, dass jeder Ausdruck, der von allen Eigenschaften des unteren Werts (der nicht vorhanden ist) abhängen muss, auch den unteren Wert ergibt.

Um "rein" zu bleiben, müssen alle Möglichkeiten zur Generierung des Grundwerts als gleichwertig behandelt werden. Das schließt den "unteren Wert" ein, der eine Endlosschleife darstellt. Da es keine Möglichkeit gibt, zu wissen, dass einige Endlosschleifen tatsächlich unendlich sind (sie werden möglicherweise beendet, wenn Sie sie nur etwas länger ausführen), können Sie keine Eigenschaft mit einem unteren Wert untersuchen. Sie können nicht testen, ob etwas unten ist, können es nicht mit etwas anderem vergleichen, können es nicht in eine Zeichenfolge konvertieren, nichts. Alles, was Sie mit einer tun können, ist, sie unberührt und ungeprüft zu platzieren (Funktionsparameter, Teil einer Datenstruktur usw.).

Python hat diese Art von Grund bereits; Es ist der "Wert", den Sie von einem Ausdruck erhalten, der eine Ausnahme auslöst oder nicht beendet. Da Python eher streng als faul ist, können solche "Unterteile" nirgendwo gespeichert und möglicherweise ungeprüft gelassen werden. Es ist also nicht wirklich notwendig, das Konzept des unteren Werts zu verwenden, um zu erklären, wie Berechnungen, die keinen Wert zurückgeben, weiterhin so behandelt werden können, als ob sie einen Wert hätten. Aber es gibt auch keinen Grund, warum Sie nicht so über Ausnahmen nachdenken könnten, wenn Sie wollten.

Das Auslösen von Ausnahmen wird tatsächlich als "rein" betrachtet. Es sind auffällige Ausnahmen, die die Reinheit brechen - gerade weil Sie damit etwas über bestimmte Grundwerte prüfen können, anstatt sie alle austauschbar zu behandeln. In Haskell können Sie nur Ausnahmen abfangen IO, die eine unreine Schnittstelle zulassen (dies geschieht normalerweise in einer ziemlich äußeren Schicht). Python erzwingt keine Reinheit, aber Sie können selbst entscheiden, welche Funktionen zu Ihrer "äußeren unreinen Schicht" gehören und nicht zu reinen Funktionen, und nur zulassen, dass Sie dort Ausnahmen abfangen.

Die Rückkehr Noneist völlig anders. Noneist ein nicht unterster Wert; Sie können testen, ob etwas damit übereinstimmt, und der Aufrufer der zurückgegebenen Funktion Nonewird weiterhin ausgeführt, möglicherweise unter Verwendung von None.

Wenn Sie also daran denken, eine Ausnahme auszulösen, und "nach unten zurückkehren" möchten, um Haskells Ansatz zu emulieren, tun Sie einfach gar nichts. Lass die Ausnahme sich verbreiten. Genau das meinen Haskell-Programmierer, wenn sie über eine Funktion sprechen, die einen unteren Wert zurückgibt.

Aber das ist nicht das, was funktionierende Programmierer meinen, wenn sie Ausnahmen vermeiden wollen. Funktionale Programmierer bevorzugen "Gesamtfunktionen". Diese geben für jede mögliche Eingabe immer einen gültigen Non-Bottom-Wert ihres Rückgabetyps zurück . Jede Funktion, die eine Ausnahme auslösen kann, ist also keine Gesamtfunktion.

Der Grund, warum wir Total Functions mögen, ist, dass sie viel einfacher als "Black Boxes" zu behandeln sind, wenn wir sie kombinieren und manipulieren. Wenn ich eine Total-Funktion habe, die etwas vom Typ A zurückgibt, und eine Total-Funktion, die etwas vom Typ A akzeptiert, dann kann ich die Sekunde am Ausgang der ersten aufrufen, ohne etwas über die Implementierung von beiden zu wissen ; Ich weiß, dass ich ein gültiges Ergebnis erhalten werde, unabhängig davon, wie der Code einer der beiden Funktionen in Zukunft aktualisiert wird (solange ihre Gesamtheit erhalten bleibt und solange sie dieselbe Typensignatur behalten). Diese Trennung von Bedenken kann ein äußerst wirksames Hilfsmittel für das Refactoring sein.

Es ist auch etwas notwendig für zuverlässige Funktionen höherer Ordnung (Funktionen, die andere Funktionen manipulieren). Wenn ich Code schreiben möchten , die eine völlig willkürliche Funktion (mit einer bekannten Schnittstelle) als Parameter erhält ich habe es als Blackbox zu behandeln , weil ich keine Möglichkeit zu wissen , haben die Eingänge möglicherweise einen Fehler auslösen. Wenn mir eine Gesamtfunktion gegeben wird, verursacht keine Eingabe einen Fehler. Ebenso weiß der Aufrufer meiner übergeordneten Funktion nicht genau, welche Argumente ich verwende, um die von ihm übergebene Funktion aufzurufen (es sei denn, er möchte von meinen Implementierungsdetails abhängen). Wenn er also eine Gesamtfunktion übergibt, muss er sich keine Sorgen machen was ich damit mache.

Ein funktionaler Programmierer, der Ihnen rät, Ausnahmen zu vermeiden, würde es vorziehen, stattdessen einen Wert zurückzugeben, der entweder den Fehler oder einen gültigen Wert codiert, und der voraussetzt, dass Sie bereit sind, beide Möglichkeiten zu nutzen. Dinge wie EitherTypen oder Maybe/ Option-Typen sind einige der einfachsten Ansätze, um dies in stärker typisierten Sprachen zu tun (normalerweise verwendet mit spezieller Syntax oder Funktionen höherer Ordnung, um Dinge, die ein brauchen, Amit Dingen zusammenzukleben, die ein erzeugen Maybe<A>).

Eine Funktion, die entweder None(wenn ein Fehler aufgetreten ist) oder einen Wert (wenn kein Fehler aufgetreten ist) zurückgibt, folgt keiner der oben genannten Strategien.

In Python mit Duck-Typisierung wird der Either / Maybe-Stil nicht sehr häufig verwendet. Stattdessen werden Ausnahmen ausgelöst, um zu überprüfen, ob der Code funktioniert, und nicht um sicherzustellen, dass Funktionen vollständig sind und basierend auf ihren Typen automatisch kombiniert werden können. Python hat keine Möglichkeit, zu erzwingen, dass Code Dinge wie Vielleicht Typen richtig verwendet. Selbst wenn Sie es aus Gründen der Disziplin einsetzen, benötigen Sie Tests, um Ihren Code tatsächlich auszuüben und dies zu überprüfen. Daher ist der Ausnahmen- / Bottom-Ansatz wahrscheinlich eher für die reine Funktionsprogrammierung in Python geeignet.

Ben
quelle
1
+1 Super Antwort! Und auch sehr umfangreich.
Andres F.
6

Solange es keine äußerlich sichtbaren Nebenwirkungen gibt und der Rückgabewert ausschließlich von den Eingaben abhängt, ist die Funktion rein, auch wenn sie intern einige eher unreine Dinge tut.

Es kommt also wirklich darauf an, was genau dazu führen kann, dass Ausnahmen ausgelöst werden. Wenn Sie versuchen, eine Datei mit einem angegebenen Pfad zu öffnen, ist dies nicht der Fall, da die Datei möglicherweise vorhanden ist oder nicht, was dazu führen würde, dass der Rückgabewert für dieselbe Eingabe variiert.

Wenn Sie andererseits versuchen, eine Ganzzahl aus einer bestimmten Zeichenfolge zu analysieren und eine Ausnahme auszulösen, wenn sie fehlschlägt, kann dies rein sein, solange keine Ausnahmen aus Ihrer Funktion herausspringen können.

Auf einer Seite zur Kenntnis, funktionale Sprachen neigen dazu , den zurückEinheit Typen nur , wenn es nur ein einziger möglicher Fehlerzustand ist. Wenn mehrere mögliche Fehler vorliegen, geben sie in der Regel einen Fehlertyp mit Informationen zum Fehler zurück.

8bittree
quelle
Sie können bottom type nicht zurückgeben.
Gardenhead
1
@gardenhead Du hast recht, ich dachte an den Einheitentyp. Fest.
8bittree
Im Fall Ihres ersten Beispiels ist das Dateisystem und welche darin enthaltenen Dateien nicht einfach eine der Eingaben für die Funktion?
Vality
2
@Vaility In Wirklichkeit haben Sie wahrscheinlich ein Handle oder einen Pfad zur Datei. Wenn die Funktion frein ist, wird erwartet f("/path/to/file"), dass immer derselbe Wert zurückgegeben wird. Was passiert, wenn die aktuelle Datei zwischen zwei Aufrufen von gelöscht oder geändert wird f?
Andres F.
2
@Vaility Die Funktion kann rein sein, wenn Sie anstelle eines Handles an die Datei den tatsächlichen Inhalt (dh die genaue Momentaufnahme der Bytes) der Datei übergeben. In diesem Fall können Sie sicherstellen, dass bei gleichem Inhalt die gleiche Ausgabe erfolgt geht raus. Aber auf eine Datei zuzugreifen ist nicht so :)
Andres F.