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ć None
nie 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 optional
funkcja -dekorowana zwraca Optional
wartoś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). Carry
tworzy opóźnioną ocenę, w której każdy krok (opóźnione wywołanie funkcji) albo otrzymuje niepuste Optional
wyjś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/except
blok 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
źródło
Odpowiedzi:
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ę:
wiemy, że
do_thing
nigdy nie może wrócić, ponieważ musiałby zwrócić wartość typuBottom
. Zatem są tylko dwie możliwości:do_thing
nie zatrzymuje siędo_thing
zgłasza wyjątek (w językach z mechanizmem wyjątku)Zauważ, że stworzyłem typ,
Bottom
który tak naprawdę nie istnieje w języku Python.None
jest mylący; w rzeczywistości jest to wartość jednostki , jedyna wartość typu jednostki , która jest wywoływanaNoneType
w Pythonie (zróbtype(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
Option
typu. Na przykład utworzymy bezpieczną funkcję podziału w następujący sposób:Niestety, ponieważ Python tak naprawdę nie ma typów sum, nie jest to realne podejście. Państwo mogli powrócić
None
jako biedny człowiek-Type opcja awarii oznaczać, ale to naprawdę nie jest lepsza niż powrocieNull
. 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.
źródło
None
nie 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?try/except/finally
jako innej alternatywyif/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żywanieif/else
do tego bloków). Czy masz na myśli, że jestem nierozsądny i powinienem dopuszczać takie wzorce, ponieważ „to jest Python”?try... 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_div
try: result = num / div: except ArithmeticError: result = None
if ... else...
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?
źródło
T + { Exception }
(gdzieT
jest 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.map : (A->B)->List A ->List B
gdzieA->B
może wystąpić błąd. Jeśli pozwolimy, aby f rzucił wyjątekmap f L
, zwróci coś typuException + List<B>
. Jeśli zamiast tego pozwolimy, aby zwróciłoptional
typ stylumap f L
, zwróci List <Opcjonalnie <B>> `. Ta druga opcja wydaje mi się bardziej funkcjonalna.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
None
nie 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,
IO
któ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
None
zamiast tego jest zupełnie inny.None
jest wartością inną niż najniższa; możesz sprawdzić, czy coś jest równe, a obiekt wywołujący zwróconą funkcjęNone
będzie nadal działał, prawdopodobnie używającNone
niewł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
Either
typy lubMaybe
/Option
typy 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ąA
z 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.
źródło
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.
źródło
f
jest czysta, można oczekiwać, żef("/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łaniamif
?