Gestione delle eccezioni di stile funzionale


24

Mi è stato detto che nella programmazione funzionale non si dovrebbe gettare e / o osservare eccezioni. Invece un calcolo errato dovrebbe essere valutato come valore inferiore. In Python (o in altri linguaggi che non incoraggiano completamente la programmazione funzionale) si può tornare None(o un'altra alternativa trattata come valore di fondo, sebbene Nonenon sia strettamente conforme alla definizione) ogni volta che qualcosa va storto per "rimanere puro", ma per fare quindi bisogna innanzitutto osservare un errore, ad es

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

Questo viola la purezza? E se è così, significa che è impossibile gestire gli errori puramente in Python?

Aggiornare

Nel suo commento Eric Lippert mi ha ricordato un altro modo di trattare le eccezioni in FP. Anche se non l'ho mai visto in pratica in Python, ci ho giocato quando ho studiato FP un anno fa. Qui qualsiasi optionalfunzione decorata restituisce Optionalvalori, che possono essere vuoti, per output normali e per un elenco specificato di eccezioni (eccezioni non specificate possono ancora terminare l'esecuzione). Carrycrea una valutazione ritardata, in cui ogni passaggio (chiamata di funzione ritardata) ottiene un Optionaloutput non vuoto dal passaggio precedente e semplicemente lo passa, oppure valuta se stesso passando un nuovo Optional. Alla fine il valore finale è normale o Empty. Qui il try/exceptblocco è nascosto dietro un decoratore, quindi le eccezioni specificate possono essere considerate come parte della firma del tipo restituito.

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

1
Alla tua domanda è già stata data una risposta, quindi solo un commento: capisci perché avere la tua funzione che genera un'eccezione è disapprovato nella programmazione funzionale? Non è un capriccio arbitrario :)
Andres F.

6
Esiste un'altra alternativa alla restituzione di un valore che indica l'errore. Ricorda, la gestione delle eccezioni è il flusso di controllo e disponiamo di meccanismi funzionali per reimpostare i flussi di controllo. È possibile emulare la gestione delle eccezioni in linguaggi funzionali scrivendo il metodo per assumere due funzioni: la continuazione del successo e la continuazione dell'errore . L'ultima cosa che fa la tua funzione è chiamare la continuazione del successo, passando il "risultato", o la continuazione dell'errore, passando la "eccezione". Il lato negativo è che devi scrivere il tuo programma in modo capovolto.
Eric Lippert,

3
No, quale sarebbe lo stato in questo caso? Ci sono molti problemi, ma qui ce ne sono alcuni: 1- c'è un possibile flusso che non è codificato nel tipo, cioè non puoi sapere se una funzione genererà un'eccezione semplicemente osservando il suo tipo (a meno che tu non abbia solo controllato eccezioni, ovviamente, ma non conosco alcuna lingua che li abbia solo ). Stai effettivamente lavorando "al di fuori" del sistema dei tipi, i programmatori a 2 funzioni si sforzano di scrivere funzioni "totali" ogni volta che è possibile, ovvero funzioni che restituiscono un valore per ogni ingresso (salvo la non terminazione). Le eccezioni funzionano contro questo.
Andres F.,

3
3- quando si lavora con funzioni totali, è possibile comporle con altre funzioni e utilizzarle in funzioni di ordine superiore senza preoccuparsi dei risultati dell'errore "non codificati nel tipo", ovvero delle eccezioni.
Andres F.

1
@EliKorvigo L'impostazione di una variabile introduce lo stato, per definizione, punto e punto. L'intero scopo delle variabili è che mantengono lo stato. Ciò non ha nulla a che fare con le eccezioni. Osservare il valore restituito di una funzione e impostare il valore di una variabile in base all'osservazione introduce lo stato nella valutazione, non è vero?
user253751,

Risposte:


20

Prima di tutto, chiariamo alcune idee sbagliate. Non esiste un "valore inferiore". Il tipo inferiore è definito come un tipo che è un sottotipo di ogni altro tipo nella lingua. Da ciò, si può dimostrare (almeno in qualsiasi sistema di tipo interessante), che il tipo di fondo non ha valori : è vuoto . Quindi non esiste un valore inferiore.

Perché il tipo di fondo è utile? Bene, sapendo che è vuoto, facciamo alcune deduzioni sul comportamento del programma. Ad esempio, se abbiamo la funzione:

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

sappiamo che do_thingnon potrà mai tornare, dal momento che dovrebbe restituire un valore di tipo Bottom. Pertanto, ci sono solo due possibilità:

  1. do_thing non si ferma
  2. do_thing genera un'eccezione (in lingue con un meccanismo di eccezione)

Nota che ho creato un tipo Bottomche in realtà non esiste nel linguaggio Python. Noneè un termine improprio; in realtà è il valore unitario , l'unico valore del tipo di unità , che viene chiamato NoneTypein Python (fai type(None)per confermare tu stesso).

Ora, un altro malinteso è che i linguaggi funzionali non hanno eccezioni. Neanche questo è vero. SML, ad esempio, ha un meccanismo di eccezione molto bello. Tuttavia, le eccezioni vengono utilizzate in modo molto più parsimonioso in SML rispetto ad esempio in Python. Come hai detto, il modo più comune per indicare un tipo di errore nei linguaggi funzionali è restituire un Optiontipo. Ad esempio, creeremmo una funzione di divisione sicura come segue:

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

Sfortunatamente, dal momento che Python in realtà non ha tipi di somma, questo non è un approccio praticabile. Si potrebbe ritornare Nonecome tipo di opzione di poveri-man al fallimento significano, ma questo è davvero niente di meglio che tornare Null. Non esiste una sicurezza di tipo.

Quindi consiglierei di seguire le convenzioni della lingua in questo caso. Python usa le eccezioni in modo idiomatico per gestire il flusso di controllo (che è una cattiva progettazione, IMO, ma è comunque standard), quindi a meno che tu non stia lavorando solo con il codice che hai scritto tu, consiglierei di seguire la pratica standard. Se questo è "puro" o no è irrilevante.


Per "valore inferiore" intendevo in realtà il "tipo inferiore", ecco perché ho scritto che Nonenon era conforme alla definizione. Comunque, grazie per avermi corretto. Non pensi che usare l'eccezione solo per interrompere completamente l'esecuzione o per restituire un valore opzionale sia corretto con i principi di Python? Voglio dire, perché è male trattenersi dall'usare l'eccezione per un controllo complicato?
Eli Korvigo,

@EliKorvigo Ecco cosa ho detto più o meno, giusto? Le eccezioni sono Python idiomatico.
gardenhead,

1
Ad esempio, scoraggio i miei studenti universitari a usare try/except/finallycome un'altra alternativa if/else, vale a dire try: var = expession1; except ...: var = expression 2; except ...: var = expression 3..., sebbene sia una cosa comune fare in qualsiasi linguaggio imperativo (a proposito, scoraggio fortemente anche l'uso di if/elseblocchi per questo). Intendi dire che sono irragionevole e che dovrei consentire tali schemi dal momento che "questo è Python"?
Eli Korvigo,

@EliKorvigo Sono d'accordo con te in generale (a proposito, sei un professore?). nontry... catch... dovrebbe essere usato per il flusso di controllo. Per qualche ragione, è così che la comunità Python ha deciso di fare le cose però. Ad esempio, la funzione che ho scritto sopra sarebbe di solito scritta . Quindi, se stai insegnando loro principi generali di ingegneria del software, dovresti assolutamente scoraggiarlo. è anche un odore di codice, ma è troppo lungo per entrare qui. safe_divtry: result = num / div: except ArithmeticError: result = Noneif ... else...
gardenhead,

2
C'è un "valore di fondo" (è usato nel parlare della semantica di Haskell, per esempio), e ha poco a che fare con il tipo di fondo. Quindi non è proprio un'idea sbagliata dell'OP, solo parlare di una cosa diversa.
Ben

11

Dato che negli ultimi giorni c'è stato così tanto interesse per la purezza, perché non esaminiamo l'aspetto di una pura funzione.

Una pura funzione:

  • È referenzialmente trasparente; cioè, per un dato input, produrrà sempre lo stesso output.

  • Non produce effetti collaterali; non cambia gli ingressi, le uscite o qualsiasi altra cosa nel suo ambiente esterno. Produce solo un valore di ritorno.

Quindi chiediti. La tua funzione non fa altro che accettare un input e restituire un output?


3
Quindi, non importa, quanto brutto è scritto dentro fintanto che si comporta in modo funzionale?
Eli Korvigo,

8
Esatto, se sei semplicemente interessato alla purezza. Naturalmente, ci potrebbero essere altre cose da considerare.
Robert Harvey,

3
Per interpretare l'avvocato dei diavoli, direi che lanciare un'eccezione è solo una forma di output e le funzioni che lanciano sono pure. È un problema?
Bergi,

1
@Bergi Non so che stai interpretando l'avvocato del diavolo, poiché questo è esattamente ciò che implica questa risposta :) Il problema è che ci sono altre considerazioni oltre alla purezza. Se si consentono eccezioni non selezionate (che per definizione non fanno parte della firma della funzione), il tipo restituito di ogni funzione diventa effettivamente T + { Exception }(dove si Ttrova il tipo restituito esplicitamente dichiarato), il che è problematico. Non puoi sapere se una funzione genererà un'eccezione senza guardare il suo codice sorgente, il che rende problematica anche la scrittura di funzioni di ordine superiore.
Andres F.

2
@Begri mentre lanciare potrebbe essere discutibilmente puro, IMO usando le eccezioni non si limita a complicare il tipo di ritorno di ogni funzione. Danneggia la componibilità delle funzioni. Considera l'implementazione di map : (A->B)->List A ->List Bwhere A->Bcan error. Se permettiamo a f di generare un'eccezione, map f L verrà restituito qualcosa di tipo Exception + List<B>. Se invece gli consentiamo di restituire un optionaltipo di stile, map f Lverrà invece restituito Elenco <Opzionale <B>> `. Questa seconda opzione mi sembra più funzionale.
Michael Anderson,

7

La semantica di Haskell utilizza un "valore inferiore" per analizzare il significato del codice Haskell. Non è qualcosa che usi davvero direttamente nella programmazione di Haskell e tornare Nonenon è affatto lo stesso tipo di cosa.

Il valore inferiore è il valore attribuito dalla semantica di Haskell a qualsiasi calcolo che non riesce a valutare normalmente un valore. Un modo in cui un calcolo di Haskell può farlo è in realtà lanciando un'eccezione! Quindi, se stavi provando a usare questo stile in Python, dovresti effettivamente generare eccezioni normalmente.

La semantica di Haskell utilizza il valore inferiore perché Haskell è pigro; sei in grado di manipolare "valori" restituiti da calcoli che non sono ancora stati effettivamente eseguiti. Puoi passarli a funzioni, inserirli in strutture di dati, ecc. Un calcolo così non valutato potrebbe generare un'eccezione o un ciclo per sempre, ma se non avessimo realmente bisogno di esaminare il valore, il calcolo non lo farà maieseguire e riscontrare l'errore e il nostro programma generale potrebbe riuscire a fare qualcosa di ben definito e finire. Quindi, senza voler spiegare cosa significhi il codice Haskell specificando l'esatto comportamento operativo del programma in fase di esecuzione, dichiariamo invece che tali calcoli errati producono il valore inferiore e spieghiamo cosa si comporta; in sostanza, qualsiasi espressione che deve dipendere da qualsiasi proprietà del valore inferiore (diverso da quello esistente) comporterà anche il valore inferiore.

Per rimanere "puri", tutti i modi possibili di generare il valore inferiore devono essere trattati come equivalenti. Ciò include il "valore inferiore" che rappresenta un ciclo infinito. Dato che non c'è modo di sapere che alcuni loop infiniti in realtà sono infiniti (potrebbero finire se li esegui per un po 'più a lungo), non puoi esaminare alcuna proprietà di un valore inferiore. Non puoi verificare se qualcosa è in basso, non puoi paragonarlo a nient'altro, non puoi convertirlo in una stringa, niente. Tutto quello che puoi fare con uno è metterlo (parametri di funzione, parte di una struttura di dati, ecc.) Intatto e non esaminato.

Python ha già questo tipo di fondo; è il "valore" che ottieni da un'espressione che genera un'eccezione o non termina. Poiché Python è rigoroso piuttosto che pigro, tali "fondi" non possono essere archiviati da nessuna parte e potenzialmente non vengono esaminati. Quindi non è necessario utilizzare il concetto del valore inferiore per spiegare come i calcoli che non riescono a restituire un valore possano ancora essere trattati come se avessero un valore. Ma non c'è nemmeno motivo per cui non potresti pensare in questo modo alle eccezioni se lo desideri.

Generare eccezioni è in realtà considerato "puro". E ' la cattura di eccezioni che le interruzioni purezza - proprio perché permette di ispezionare qualcosa su alcuni valori di fondo, invece di trattare tutti in modo intercambiabile. In Haskell puoi catturare solo eccezioni in IOquanto consente un'interfaccia impura (quindi di solito accade a un livello abbastanza esterno). Python non impone la purezza, ma puoi ancora decidere tu stesso quali funzioni fanno parte del tuo "strato impuro esterno" piuttosto che funzioni pure, e permetti solo a te stesso di catturare eccezioni lì.

Il ritorno Noneinvece è completamente diverso. Noneè un valore non inferiore; puoi verificare se qualcosa è uguale a esso, e il chiamante della funzione che ha restituito Nonecontinuerà a funzionare, possibilmente usando in modo Noneinappropriato.

Quindi, se stavi pensando di lanciare un'eccezione e vuoi "tornare in fondo" per emulare l'approccio di Haskell, non fai assolutamente nulla. Lascia che l'eccezione si propaghi. Questo è esattamente ciò che i programmatori Haskell intendono quando parlano di una funzione che restituisce un valore inferiore.

Ma questo non è ciò che i programmatori funzionali intendono quando dicono per evitare eccezioni. I programmatori funzionali preferiscono le "funzioni totali". Restituiscono sempre un valore non inferiore valido del loro tipo restituito per ogni possibile input. Quindi qualsiasi funzione che può generare un'eccezione non è una funzione totale.

Il motivo per cui ci piacciono le funzioni totali è che sono molto più facili da trattare come "scatole nere" quando le combiniamo e le manipoliamo. Se ho una funzione totale che restituisce qualcosa di tipo A e una funzione totale che accetta qualcosa di tipo A, allora posso chiamare il secondo sull'output del primo, senza sapere nulla sull'implementazione di nessuno dei due; So che otterrò un risultato valido, indipendentemente dal modo in cui il codice di entrambe le funzioni verrà aggiornato in futuro (purché venga mantenuta la loro totalità e finché manterranno la stessa firma di tipo). Questa separazione delle preoccupazioni può essere un aiuto estremamente potente per il refactoring.

È anche necessario per funzioni affidabili di ordine superiore (funzioni che manipolano altre funzioni). Se voglio scrivere il codice che riceve una funzione del tutto arbitrario (con un'interfaccia noto) come parametro io devo trattarlo come una scatola nera perché non ho modo di sapere quali ingressi potrebbero innescare un errore. Se mi viene data una funzione totale, nessun input provocherà un errore. Allo stesso modo il chiamante della mia funzione di ordine superiore non saprà esattamente quali argomenti utilizzare per chiamare la funzione che mi passa (a meno che non voglia dipendere dai miei dettagli di implementazione), quindi passare una funzione totale significa che non devono preoccuparsi cosa ci faccio.

Quindi un programmatore funzionale che ti consiglia di evitare le eccezioni preferirebbe che tu restituisse un valore che codifica l'errore o un valore valido e richiede che per usarlo sei pronto a gestire entrambe le possibilità. Cose come Eithertipi o Maybe/ Optiontipi sono alcuni dei più semplici approcci di fare questo in lingue più fortemente tipizzati (di solito usati con la sintassi speciale o funzioni di ordine superiore per l'aiuto di colla cose insieme che hanno bisogno di una Acon le cose che producono una Maybe<A>).

Una funzione che restituisce None(se si è verificato un errore) o un valore (se non si è verificato un errore) non sta seguendo nessuna delle strategie precedenti.

In Python con duck digitando lo stile Oither / Maybe non viene utilizzato molto, invece si generano eccezioni, con i test per convalidare che il codice funzioni piuttosto che fidarsi delle funzioni per essere totali e automaticamente combinabili in base ai loro tipi. Python non ha alcuna possibilità di imporre che il codice usi cose come i tipi Forse correttamente; anche se lo stavi usando come una questione di disciplina, hai bisogno di test per esercitare effettivamente il tuo codice per convalidarlo. Quindi l'approccio eccezione / fondo è probabilmente più adatto alla pura programmazione funzionale in Python.


1
+1 Risposta fantastica! E anche molto completo.
Andres F.

6

Finché non ci sono effetti collaterali visibili esternamente e il valore di ritorno dipende esclusivamente dagli input, allora la funzione è pura, anche se al suo interno fa cose piuttosto impure.

Quindi dipende davvero da cosa può causare esattamente l'eccezione. Se stai provando ad aprire un file con un percorso, no, non è puro, perché il file potrebbe esistere o meno, il che farebbe variare il valore di ritorno per lo stesso input.

D'altra parte, se stai cercando di analizzare un numero intero da una determinata stringa e lanciare un'eccezione se fallisce, ciò potrebbe essere puro, a condizione che nessuna eccezione possa fuoriuscire dalla tua funzione.

In una nota a margine, i linguaggi funzionali tendono a restituire il tipo di unità solo se esiste una sola possibile condizione di errore. Se ci sono più possibili errori, tendono a restituire un tipo di errore con informazioni sull'errore.


Non è possibile restituire il tipo di fondo.
gardenhead,

1
@gardenhead Hai ragione, stavo pensando al tipo di unità. Fisso.
settembre

Nel caso del tuo primo esempio, il filesystem e quali file al suo interno non sono semplicemente uno degli input della funzione?
Valità,

2
@Vaility In realtà probabilmente hai un handle o un percorso per il file. Se la funzione fè pura, ti aspetteresti f("/path/to/file")di restituire sempre lo stesso valore. Cosa succede se il file effettivo viene eliminato o modificato tra due invocazioni f?
Andres F.

2
@Vaility La funzione potrebbe essere pura se invece di un handle per il file gli hai passato il contenuto effettivo (ovvero l'istantanea esatta dei byte) del file, nel qual caso puoi garantire che se lo stesso contenuto viene inserito, lo stesso output esce. Ma accedere a un file non è così :)
Andres F.
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.