Obsługa wyjątków w stylu funkcjonalnym

24

Powiedziano mi, że w programowaniu funkcjonalnym nie należy rzucać i / lub obserwować wyjątków. Zamiast tego błędne obliczenia należy ocenić jako dolną wartość. W Pythonie (lub innych językach, które nie w pełni zachęcają do programowania funkcjonalnego) można zwrócić None(lub inną alternatywę traktowaną jako dolna wartość, choć Nonenie jest ściśle zgodna z definicją), gdy coś pójdzie nie tak, aby „pozostać czystym”, ale zrobić więc należy przede wszystkim zauważyć błąd, tj

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

Czy to narusza czystość? A jeśli tak, to czy oznacza to, że niemożliwe jest obsługiwanie błędów wyłącznie w języku Python?

Aktualizacja

W swoim komentarzu Eric Lippert przypomniał mi o innym sposobie traktowania wyjątków w FP. Chociaż nigdy nie widziałem tego w Pythonie w praktyce, bawiłem się nim, kiedy studiowałem FP rok temu. Tutaj dowolna optionalfunkcja -dekorowana zwraca Optionalwartości, które mogą być puste, zarówno dla normalnych danych wyjściowych, jak i dla określonej listy wyjątków (nieokreślone wyjątki nadal mogą zakończyć wykonywanie). Carrytworzy opóźnioną ocenę, w której każdy krok (opóźnione wywołanie funkcji) albo otrzymuje niepuste Optionalwyjście z poprzedniego kroku i po prostu przekazuje go, lub w inny sposób ocenia siebie, przekazując nowy Optional. Ostatecznie wartość końcowa jest albo normalna, albo Empty. Tutaj try/exceptblok jest ukryty za dekoratorem, więc określone wyjątki można traktować jako część podpisu typu zwracanego.

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
źródło
1
Odpowiedzi na twoje pytanie już zostały udzielone, więc wystarczy komentarz: czy rozumiesz, dlaczego odrzucenie wyjątku przez twoją funkcję jest w programowaniu funkcjonalnym odrzucone? To nie jest arbitralna kaprys :)
Andres F.,
6
Istnieje inna alternatywa dla zwracania wartości wskazującej błąd. Pamiętaj, że obsługa wyjątków jest przepływem kontrolnym, a my mamy funkcjonalne mechanizmy do realizowania przepływów kontrolnych. Można emulować obsługę wyjątków w językach funkcjonalnych, pisząc metodę, aby przyjąć dwie funkcje: kontynuację sukcesu i kontynuację błędu . Ostatnią rzeczą, którą wykonuje funkcja, jest wywołanie kontynuacji sukcesu, przekazanie „wyniku” lub kontynuacja błędu, przekazanie „wyjątku”. Wadą jest to, że musisz napisać swój program w ten wywrotowy sposób.
Eric Lippert
3
Nie, jaki byłby stan w tym przypadku? Istnieje wiele problemów, ale tutaj jest kilka: 1- istnieje jeden możliwy przepływ, który nie jest zakodowany w typie, tzn. Nie możesz wiedzieć, czy funkcja zgłosi wyjątek, patrząc tylko na jego typ (chyba że zaznaczyłeś tylko wyjątki, oczywiście, ale ja nie znam żadnego języka, który tylko je ma). Skutecznie pracujesz „na zewnątrz” systemu typów, 2-funkcyjni programiści starają się pisać funkcje „ogółem”, gdy tylko jest to możliwe, tj. Funkcje, które zwracają wartość dla każdego wejścia (z wyjątkiem braku zakończenia). Wyjątki działają przeciwko temu.
Andres F.,
3
3- gdy pracujesz z funkcjami ogółem, możesz komponować je z innymi funkcjami i używać ich w funkcjach wyższego rzędu bez obawy o wyniki błędów „nie zakodowane w typie”, tj. Wyjątki.
Andres F.,
1
@EliKorvigo Ustawienie zmiennej wprowadza stan, z definicji, kropkę. Głównym celem zmiennych jest utrzymanie stanu. To nie ma nic wspólnego z wyjątkami. Obserwowanie wartości zwracanej przez funkcję i ustawianie wartości zmiennej na podstawie obserwacji wprowadza stan do oceny, prawda?
user253751

Odpowiedzi:

20

Przede wszystkim wyjaśnijmy kilka nieporozumień. Nie ma „dolnej wartości”. Typ dolny jest zdefiniowany jako typ, który jest podtypem każdego innego typu w języku. Na podstawie tego można udowodnić (przynajmniej w dowolnym interesującym systemie typów), że typ dolny nie ma żadnych wartości - jest pusty . Więc nie ma czegoś takiego jak dolna wartość.

Dlaczego typ dna jest użyteczny? Cóż, wiedząc, że jest pusty, dokonajmy pewnych wniosków na temat zachowania programu. Na przykład, jeśli mamy funkcję:

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

wiemy, że do_thingnigdy nie może wrócić, ponieważ musiałby zwrócić wartość typu Bottom. Zatem są tylko dwie możliwości:

  1. do_thing nie zatrzymuje się
  2. do_thing zgłasza wyjątek (w językach z mechanizmem wyjątku)

Zauważ, że stworzyłem typ, Bottomktóry tak naprawdę nie istnieje w języku Python. Nonejest mylący; w rzeczywistości jest to wartość jednostki , jedyna wartość typu jednostki , która jest wywoływana NoneTypew Pythonie (zrób type(None)to, aby samemu potwierdzić).

Kolejnym nieporozumieniem jest to, że języki funkcjonalne nie mają wyjątku. To też nie jest prawda. Na przykład SML ma bardzo ładny mechanizm wyjątków. Jednak wyjątki są używane o wiele oszczędniej w SML niż np. W Pythonie. Jak już powiedziałeś, powszechnym sposobem na wskazanie pewnego rodzaju awarii w językach funkcjonalnych jest zwrócenie Optiontypu. Na przykład utworzymy bezpieczną funkcję podziału w następujący sposób:

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

Niestety, ponieważ Python tak naprawdę nie ma typów sum, nie jest to realne podejście. Państwo mogli powrócić Nonejako biedny człowiek-Type opcja awarii oznaczać, ale to naprawdę nie jest lepsza niż powrocie Null. Nie ma bezpieczeństwa typu.

W tym przypadku radziłbym przestrzegać konwencji języka. Python używa wyjątków idiomatycznie do obsługi przepływu sterowania (co jest złym projektem, IMO, ale mimo to jest standardem), więc jeśli nie pracujesz tylko z kodem, który sam napisałeś, zaleciłbym przestrzeganie standardowej praktyki. To, czy jest to „czyste”, czy nie, nie ma znaczenia.

ogrodnik
źródło
Przez „dolną wartość” właściwie rozumiałem „dolny typ”, dlatego napisałem, że Nonenie jest zgodny z definicją. W każdym razie dzięki za poprawienie mnie. Czy nie uważasz, że stosowanie wyjątku tylko w celu całkowitego zatrzymania wykonania lub zwrócenia opcjonalnej wartości jest zgodne z zasadami Pythona? Mam na myśli, dlaczego tak źle jest powstrzymywać się od stosowania wyjątku do skomplikowanej kontroli?
Eli Korvigo
@EliKorvigo To mniej więcej to, co powiedziałem, prawda? Wyjątkami są idiomatyczny Python.
ogrodnik
1
Na przykład, odradzam moim studentom licencjackim korzystanie z nich try/except/finallyjako innej alternatywy if/else, tzn. try: var = expession1; except ...: var = expression 2; except ...: var = expression 3...Chociaż jest to powszechne w każdym imperatywnym języku (przy okazji, zdecydowanie odradzam również używanie if/elsedo tego bloków). Czy masz na myśli, że jestem nierozsądny i powinienem dopuszczać takie wzorce, ponieważ „to jest Python”?
Eli Korvigo
@EliKorvigo Zgadzam się z tobą ogólnie (btw, jesteś profesorem?). nietry... catch... powinien być stosowany do przepływu kontrolnego. Z jakiegoś powodu właśnie dlatego społeczność Python zdecydowała się to zrobić. Na przykład funkcja, którą napisałem powyżej, zwykle byłaby zapisana . Więc jeśli uczysz ich ogólnych zasad inżynierii oprogramowania, zdecydowanie powinieneś ich odradzać. jest również zapachem kodu, ale to za długo, aby się tutaj znaleźć. safe_divtry: result = num / div: except ArithmeticError: result = Noneif ... else...
ogrodnik
2
Istnieje „dolna wartość” (jest używana na przykład przy mówieniu o semantyce Haskella) i ma niewiele wspólnego z dolnym typem. Więc to nie jest tak naprawdę nieporozumienie OP, po prostu mówienie o innej rzeczy.
Ben
11

Ponieważ w ciągu ostatnich kilku dni zainteresowanie czystością było tak duże, dlaczego nie zbadamy, jak wygląda czysta funkcja.

Czysta funkcja:

  • Jest referencyjnie przejrzysty; oznacza to, że dla danego wejścia zawsze będzie generować to samo wyjście.

  • Nie powoduje skutków ubocznych; nie zmienia danych wejściowych, wyjściowych ani niczego innego w swoim środowisku zewnętrznym. Daje tylko wartość zwracaną.

Więc zadaj sobie pytanie. Czy twoja funkcja robi coś innego niż akceptuje dane wejściowe i zwraca dane wyjściowe?

Robert Harvey
źródło
3
Więc nie ma znaczenia, jak brzydko jest napisane w środku, dopóki zachowuje się funkcjonalnie?
Eli Korvigo,
8
Zgadza się, jeśli interesuje Cię czystość. Można oczywiście rozważyć inne kwestie.
Robert Harvey
3
Aby zagrać w adwokata diabłów, twierdzę, że zgłoszenie wyjątku jest tylko formą wyników, a rzucane funkcje są czyste. Czy to problem?
Bergi,
1
@Bergi Nie wiem, czy grasz w adwokata diabła, ponieważ taka właśnie implikuje ta odpowiedź :) Problem polega na tym, że oprócz czystości istnieją inne względy. Jeśli dopuścisz niesprawdzone wyjątki (które z definicji nie są częścią podpisu funkcji), wówczas typ zwracany przez każdą funkcję skutecznie staje się T + { Exception }(gdzie Tjest jawnie zadeklarowany typ zwracany), co jest problematyczne. Nie możesz wiedzieć, czy funkcja zgłosi wyjątek bez spojrzenia na kod źródłowy, co sprawia, że ​​pisanie funkcji wyższego rzędu również stanowi problem.
Andres F.,
2
@Begri podczas rzucania może być prawdopodobnie czyste, IMO za pomocą wyjątków nie tylko komplikuje typ zwracanych funkcji. Niszczy składalność funkcji. Zastanów się, map : (A->B)->List A ->List Bgdzie A->Bmoże wystąpić błąd. Jeśli pozwolimy, aby f rzucił wyjątek map f L , zwróci coś typu Exception + List<B>. Jeśli zamiast tego pozwolimy, aby zwrócił optionaltyp stylu map f L, zwróci List <Opcjonalnie <B>> `. Ta druga opcja wydaje mi się bardziej funkcjonalna.
Michael Anderson
7

Semantyka Haskell używa „dolnej wartości” do analizy znaczenia kodu Haskell. Nie jest to coś, czego tak naprawdę używasz bezpośrednio w programowaniu Haskell, a powrót Nonenie jest wcale taki sam.

Dolna wartość to wartość przypisywana semantyce Haskell każdemu obliczeniu, którego normalna wartość nie jest możliwa. Jednym z takich sposobów obliczeń Haskella jest rzucenie wyjątku! Więc jeśli próbujesz użyć tego stylu w Pythonie, powinieneś po prostu generować wyjątki jak zwykle.

Semantyka Haskell używa dolnej wartości, ponieważ Haskell jest leniwy; możesz manipulować „wartościami” zwracanymi przez obliczenia, które jeszcze się nie uruchomiły. Możesz przekazać je do funkcji, umieścić je w strukturach danych itp. Takie nieocenione obliczenia mogą narzucić wyjątek lub zapętlić na zawsze, ale jeśli nigdy nie będziemy musieli sprawdzać wartości, wówczas obliczenia nigdyuruchomić i napotkać błąd, a nasz ogólny program może zrobić coś dobrze zdefiniowanego i zakończyć. Więc nie chcąc wyjaśniać, co oznacza kod Haskell, określając dokładne zachowanie operacyjne programu w czasie wykonywania, zamiast tego deklarujemy, że takie błędne obliczenia dają dolną wartość i wyjaśniamy, co ta wartość zachowuje; w zasadzie każde wyrażenie, które musi zależeć od dowolnych właściwości przy całej dolnej wartości (innej niż istniejąca), również spowoduje dolną wartość.

Aby pozostać „czystym”, wszystkie możliwe sposoby generowania dolnej wartości należy traktować jako równoważne. Obejmuje to „dolną wartość” reprezentującą nieskończoną pętlę. Ponieważ nie ma sposobu, aby wiedzieć, że niektóre nieskończone pętle są w rzeczywistości nieskończone (mogą zakończyć się, jeśli uruchomisz je nieco dłużej), nie możesz zbadać żadnej właściwości o dolnej wartości. Nie można sprawdzić, czy coś jest na dole, nie można go porównać z niczym innym, nie można przekonwertować na ciąg znaków, nic. Jedyne, co możesz zrobić, to pozostawić nietknięte i niezbadane miejsca (parametry funkcji, część struktury danych itp.).

Python ma już tego rodzaju dno; jest to „wartość” uzyskana z wyrażenia, które zgłasza wyjątek lub się nie kończy. Ponieważ Python jest bardziej rygorystyczny niż leniwy, takie „dna” nie mogą być nigdzie przechowywane i potencjalnie pozostaną niezbadane. Dlatego nie ma potrzeby używania koncepcji dolnej wartości do wyjaśnienia, w jaki sposób obliczenia, które nie zwracają wartości, mogą być nadal traktowane tak, jakby miały wartość. Ale nie ma również powodu, dla którego nie można tak myśleć o wyjątkach, jeśli chcesz.

Zgłaszanie wyjątków jest w rzeczywistości uważane za „czyste”. To łapanie wyjątków rozkładający czystości - właśnie dlatego, że pozwala kontrolować coś o pewnych wartościach dolny, zamiast traktować je wszystkie zamiennie. W Haskell można wychwycić tylko wyjątki, IOktóre pozwalają na nieczyste połączenia (więc zwykle dzieje się to na dość zewnętrznej warstwie). Python nie wymusza czystości, ale nadal możesz sam decydować, które funkcje są częścią twojej „zewnętrznej nieczystej warstwy”, a nie tylko funkcje czyste, i pozwolić sobie jedynie na wychwycenie wyjątków.

Powrót Nonezamiast tego jest zupełnie inny. Nonejest wartością inną niż najniższa; możesz sprawdzić, czy coś jest równe, a obiekt wywołujący zwróconą funkcję Nonebędzie nadal działał, prawdopodobnie używając Noneniewłaściwie.

Więc jeśli zastanawiasz się nad rzuceniem wyjątku i chcesz „wrócić na dół”, aby naśladować podejście Haskella, po prostu nic nie rób. Niech propaguje się wyjątek. Dokładnie to mają na myśli programiści Haskell, kiedy mówią o funkcji zwracającej najniższą wartość.

Ale nie to mają na myśli funkcjonalni programiści, kiedy mówią, aby unikać wyjątków. Funkcjonalni programiści preferują „funkcje całkowite”. Zawsze zwracają one prawidłową wartość dolną typu zwracanego dla każdego możliwego wejścia. Tak więc każda funkcja, która może zgłosić wyjątek, nie jest funkcją całkowitą.

Powodem, dla którego lubimy funkcje całkowite, jest to, że łatwiej je traktować jako „czarne skrzynki”, kiedy je łączymy i manipulujemy. Jeśli mam całkowitą funkcję zwracającą coś typu A i całkowitą funkcję, która akceptuje coś typu A, to mogę wywołać drugą na wyjściu pierwszego, nie wiedząc nic o implementacji żadnego z nich; Wiem, że otrzymam prawidłowy wynik, bez względu na to, w jaki sposób kod każdej funkcji zostanie zaktualizowany w przyszłości (o ile zachowana będzie ich całość i dopóki będą one miały ten sam typ podpisu). To rozdzielenie obaw może być niezwykle potężną pomocą przy refaktoryzacji.

Jest to również w pewnym stopniu niezbędne do niezawodnych funkcji wyższego rzędu (funkcji, które manipulują innymi funkcjami). Jeśli chcę napisać kod, który odbiera zupełnie dowolną funkcję (ze znanego interfejsu) jako parametr ja mam traktować go jako czarną skrzynkę, bo nie mam możliwości dowiedzenia się, które wejścia może powodować błąd. Jeśli dostanę całkowitą funkcję, żadne wejście nie spowoduje błędu. Podobnie obiekt wywołujący moją funkcję wyższego rzędu nie będzie wiedział dokładnie, jakich argumentów używam do wywołania funkcji, którą mi przekazują (chyba że chcą polegać na szczegółach mojej implementacji), więc przekazanie funkcji całkowitej oznacza, że ​​nie muszą się martwić co z tym robię.

Tak więc funkcjonalny programista, który radzi unikać wyjątków, wolałby zamiast tego zwracać wartość kodującą błąd lub prawidłową wartość, i wymaga, aby z niej korzystać, jesteś przygotowany do obsługi obu możliwości. Rzeczy takie jak Eithertypy lub Maybe/ Optiontypy są jednymi z najprostszych podejść to zrobić w bardziej silnie typowanych języków (zazwyczaj używane ze szczególnym składni lub wyższych funkcji, aby pomóc skleić rzeczy, które potrzebują Az rzeczy, które wytwarzają Maybe<A>).

Funkcja, która zwraca None(jeśli wystąpił błąd) lub pewną wartość (jeśli nie wystąpił błąd), nie spełnia żadnej z powyższych strategii.

W Pythonie z kaczym pisaniem styl Either / Może nie jest używany bardzo często, zamiast tego pozwala się zgłaszać wyjątki, testując, czy kod działa, a nie ufając, że funkcje będą całkowite i automatycznie łączone na podstawie ich typów. Python nie ma możliwości narzucenia, że ​​kod używa takich rzeczy, jak typy poprawnie; nawet jeśli używasz go w celu zachowania dyscypliny, potrzebujesz testów, aby faktycznie wykonać swój kod, aby to sprawdzić. Podejście wyjątek / dół jest prawdopodobnie bardziej odpowiednie dla czysto funkcjonalnego programowania w Pythonie.

Ben
źródło
1
+1 Świetna odpowiedź! I również bardzo wszechstronne.
Andres F.,
6

Tak długo, jak nie ma widocznych z zewnątrz efektów ubocznych, a wartość zwracana zależy wyłącznie od danych wejściowych, funkcja jest czysta, nawet jeśli powoduje pewne nieczyste rzeczy wewnętrznie.

Tak więc to naprawdę zależy od tego, co dokładnie może spowodować wygenerowanie wyjątków. Jeśli próbujesz otworzyć plik ze ścieżką, to nie, nie jest czysty, ponieważ plik może istnieć lub nie, co spowodowałoby zmianę wartości zwracanej dla tego samego wejścia.

Z drugiej strony, jeśli próbujesz parsować liczbę całkowitą z danego łańcucha i zgłaszasz wyjątek, jeśli się nie powiedzie, może to być czyste, o ile żadne wyjątki nie mogą wypalić twojej funkcji.

Na marginesie, języki funkcjonalne zwracają typ jednostki tylko wtedy, gdy występuje tylko jeden możliwy błąd. Jeśli istnieje wiele możliwych błędów, zwracają typ błędu z informacją o błędzie.

8bittree
źródło
Nie możesz zwrócić typu dolnego.
ogrodnik
1
@gardenhead Masz rację, myślałem o typie jednostki. Naprawiony.
8bittree
Czy w przypadku pierwszego przykładu system plików i jakie pliki w nim zawarte nie są po prostu jednym z danych wejściowych funkcji?
Rzeczywistość
2
@Vaility W rzeczywistości prawdopodobnie masz uchwyt lub ścieżkę do pliku. Jeśli funkcja fjest czysta, można oczekiwać, że f("/path/to/file")zawsze zwróci tę samą wartość. Co się stanie, jeśli rzeczywisty plik zostanie usunięty lub zmieniony między dwoma wywołaniami f?
Andres F.,
2
@Vaility Funkcja może być czysta, jeśli zamiast uchwytu do pliku przekazałeś jej rzeczywistą zawartość (tj. Dokładną migawkę bajtów) pliku, w takim przypadku możesz zagwarantować, że jeśli ta sama treść wejdzie, to samo wyjście wychodzi. Ale dostęp do pliku tak nie jest :)
Andres F.,