Gestion des exceptions de style fonctionnel

24

On m'a dit qu'en programmation fonctionnelle, on n'est pas censé lever et / ou observer des exceptions. Au lieu de cela, un calcul erroné doit être évalué comme une valeur inférieure. En Python (ou dans d'autres langages qui n'encouragent pas pleinement la programmation fonctionnelle), on peut retourner None(ou une autre alternative traitée comme la valeur la plus basse, bien qu'elle Nonene soit pas strictement conforme à la définition) chaque fois que quelque chose se passe mal pour "rester pur", mais pour le faire il faut donc observer une erreur en premier lieu, c'est-à-dire

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

Est-ce que cela viole la pureté? Et si oui, cela signifie-t-il qu'il est impossible de gérer les erreurs uniquement en Python?

Mise à jour

Dans son commentaire, Eric Lippert m'a rappelé une autre façon de traiter les exceptions en PF. Bien que je n'ai jamais vu cela en Python en pratique, j'ai joué avec quand j'ai étudié la PF il y a un an. Ici, toute optionalfonction -décorée renvoie des Optionalvaleurs, qui peuvent être vides, pour les sorties normales ainsi que pour une liste d'exceptions spécifiée (les exceptions non spécifiées peuvent toujours mettre fin à l'exécution). Carrycrée une évaluation retardée, où chaque étape (appel de fonction retardée) obtient une Optionalsortie non vide de l'étape précédente et la transmet simplement, ou s'évalue autrement en passant une nouvelle Optional. Au final, la valeur finale est soit normale, soit Empty. Ici, le try/exceptbloc est caché derrière un décorateur, de sorte que les exceptions spécifiées peuvent être considérées comme faisant partie de la signature de type de retour.

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
la source
1
Votre question a déjà reçu une réponse, alors juste un commentaire: comprenez-vous pourquoi avoir votre fonction lever une exception est mal vu dans la programmation fonctionnelle? Ce n'est pas un caprice arbitraire :)
Andres F.
6
Il existe une autre alternative au retour d'une valeur indiquant l'erreur. N'oubliez pas que la gestion des exceptions est un flux de contrôle et nous avons des mécanismes fonctionnels pour réifier les flux de contrôle. Vous pouvez émuler la gestion des exceptions dans les langages fonctionnels en écrivant votre méthode pour prendre deux fonctions: la poursuite de la réussite et la poursuite de l' erreur . La dernière chose que fait votre fonction est d'appeler soit la continuation du succès, en passant le «résultat», soit la continuation de l'erreur, en passant «l'exception». L'inconvénient est que vous devez écrire votre programme de cette façon.
Eric Lippert
3
Non, quel serait l'état dans ce cas? Il y a plusieurs problèmes, mais en voici quelques-uns: 1- il existe un flux possible qui n'est pas codé dans le type, c'est-à-dire que vous ne pouvez pas savoir si une fonction lèvera une exception simplement en regardant son type (sauf si vous avez seulement vérifié exceptions, bien sûr, mais je ne connais aucune langue qui ne les a). Vous travaillez effectivement "en dehors" du système de type, les programmeurs 2- fonctionnels s'efforcent d'écrire des fonctions "totales" chaque fois que possible, c'est-à-dire des fonctions qui renvoient une valeur pour chaque entrée (sauf non-terminaison). Les exceptions vont à l'encontre de cela.
Andres F.
3
3- lorsque vous travaillez avec des fonctions totales, vous pouvez les composer avec d'autres fonctions et les utiliser dans des fonctions d'ordre supérieur sans vous soucier des résultats d'erreur "non encodés dans le type", c'est-à-dire des exceptions.
Andres F.
1
@EliKorvigo La définition d'une variable introduit l'état, par définition, le point final. Le but entier des variables est qu'elles détiennent l'état. Cela n'a rien à voir avec les exceptions. L'observation de la valeur de retour d'une fonction et la définition d'une valeur de variable basée sur l'observation introduit un état dans l'évaluation, n'est-ce pas?
user253751

Réponses:

20

Tout d'abord, clarifions certaines idées fausses. Il n'y a pas de "valeur inférieure". Le type inférieur est défini comme un type qui est un sous-type de tous les autres types dans la langue. De cela, on peut prouver (dans tout système de type intéressant au moins), que le type inférieur n'a pas de valeurs - il est vide . Il n'y a donc pas de valeur minimale.

Pourquoi le type inférieur est-il utile? Eh bien, sachant qu'il est vide, faisons des déductions sur le comportement du programme. Par exemple, si nous avons la fonction:

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

nous savons que do_thingcela ne peut jamais retourner, car il devrait retourner une valeur de type Bottom. Ainsi, il n'y a que deux possibilités:

  1. do_thing ne s'arrête pas
  2. do_thing lève une exception (dans les langues avec un mécanisme d'exception)

Notez que j'ai créé un type Bottomqui n'existe pas réellement dans le langage Python. Noneest un terme impropre; c'est en fait la valeur unitaire , la seule valeur du type d'unité , qui est appelée NoneTypeen Python (faites-le type(None)pour vous-même).

Maintenant, une autre idée fausse est que les langages fonctionnels n'ont pas d'exception. Ce n'est pas vrai non plus. SML par exemple a un très bon mécanisme d'exception. Cependant, les exceptions sont utilisées beaucoup plus avec parcimonie en SML qu'en Python par exemple. Comme vous l'avez dit, la manière courante d'indiquer une sorte d'échec dans les langages fonctionnels est de renvoyer un Optiontype. Par exemple, nous créerions une fonction de division sûre comme suit:

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

Malheureusement, puisque Python n'a pas réellement de types de somme, ce n'est pas une approche viable. Vous pouvez revenir en Nonetant que type d'option de pauvre pour signifier l'échec, mais ce n'est vraiment pas mieux que de revenir Null. Il n'y a pas de sécurité de type.

Je conseillerais donc de suivre les conventions de la langue dans ce cas. Python utilise idiomatiquement les exceptions pour gérer le flux de contrôle (ce qui est une mauvaise conception, IMO, mais c'est néanmoins standard), donc à moins que vous ne travailliez qu'avec du code que vous avez écrit vous-même, je vous recommande de suivre la pratique standard. Que ce soit "pur" ou non n'a pas d'importance.

gardenhead
la source
Par "valeur inférieure", j'entendais en fait le "type inférieur", c'est pourquoi j'ai écrit que ce Nonen'était pas conforme à la définition. Quoi qu'il en soit, merci de me corriger. Ne pensez-vous pas que l'utilisation d'exception uniquement pour arrêter complètement l'exécution ou pour renvoyer une valeur facultative est conforme aux principes de Python? Je veux dire, pourquoi est-il mauvais de ne pas utiliser d'exception pour un contrôle compliqué?
Eli Korvigo
@EliKorvigo C'est à peu près ce que j'ai dit, non? Les exceptions sont Python idiomatique.
gardenhead
1
Par exemple, je décourage mes étudiants de premier cycle à utiliser try/except/finallycomme une autre alternative à if/else, c'est- à -dire try: var = expession1; except ...: var = expression 2; except ...: var = expression 3..., bien que ce soit une chose courante à faire dans n'importe quel langage impératif (btw, je déconseille fortement d'utiliser des if/elseblocs pour cela également). Voulez-vous dire que je suis déraisonnable et que je devrais autoriser de tels modèles puisque "c'est Python"?
Eli Korvigo
@EliKorvigo Je suis d'accord avec vous en général (au fait, vous êtes professeur?). netry... catch... doit pas être utilisé pour contrôler le débit. Pour une raison quelconque, c'est ainsi que la communauté Python a décidé de faire les choses. Par exemple, la safe_divfonction que j'ai écrite ci-dessus serait généralement écrite try: result = num / div: except ArithmeticError: result = None. Donc, si vous leur enseignez les principes généraux de l'ingénierie logicielle, vous devriez certainement décourager cela. if ... else...est aussi une odeur de code, mais c'est trop long pour entrer ici.
gardenhead
2
Il y a une "valeur inférieure" (elle est utilisée pour parler de la sémantique de Haskell, par exemple), et elle a peu à voir avec le type inférieur. Ce n'est donc pas vraiment une idée fausse du PO, juste parler d'une chose différente.
Ben
11

Puisqu'il y a eu tellement d'intérêt pour la pureté au cours des derniers jours, pourquoi n'examinons-nous pas à quoi ressemble une fonction pure.

Une fonction pure:

  • Est référentiellement transparent; c'est-à-dire que pour une entrée donnée, elle produira toujours la même sortie.

  • Ne produit pas d'effets secondaires; il ne modifie pas les entrées, les sorties ou quoi que ce soit d'autre dans son environnement externe. Il ne produit qu'une valeur de retour.

Alors demandez-vous. Votre fonction fait-elle autre chose que d'accepter une entrée et de renvoyer une sortie?

Robert Harvey
la source
3
Donc, peu importe, à quel point il est laid à l'intérieur tant qu'il se comporte de manière fonctionnelle?
Eli Korvigo
8
Exact, si vous êtes simplement intéressé par la pureté. Il pourrait bien sûr y avoir d'autres choses à considérer.
Robert Harvey
3
Pour incarner l'avocat des démons, je dirais que lever une exception n'est qu'une forme de sortie et que les fonctions de lancer sont pures. Est-ce un problème?
Bergi
1
@Bergi Je ne sais pas si vous jouez l'avocat du diable, car c'est précisément ce que cette réponse implique :) Le problème est qu'il y a d' autres considérations que la pureté. Si vous autorisez des exceptions non contrôlées (qui, par définition, ne font pas partie de la signature de la fonction), le type de retour de chaque fonction devient effectivement T + { Exception }(où Test le type de retour explicitement déclaré), ce qui est problématique. Vous ne pouvez pas savoir si une fonction lèvera une exception sans regarder son code source, ce qui rend également difficile l'écriture de fonctions d'ordre supérieur.
Andres F.
2
@Begri tout en lançant peut être sans doute pur, IMO utilisant des exceptions fait plus que compliquer le type de retour de chaque fonction. Cela nuit à la composabilité des fonctions. Considérez l'implémentation de l' map : (A->B)->List A ->List Bendroit où l' A->Berreur peut se produire. Si nous permettons à f de lever une exception, map f L nous retournerons quelque chose de type Exception + List<B>. Si, au contraire, nous lui permettons de renvoyer un optionaltype de style map f L, il renvoie à la place List <Facultatif <B>> `. Cette deuxième option me semble plus fonctionnelle.
Michael Anderson
7

La sémantique Haskell utilise une "valeur inférieure" pour analyser la signification du code Haskell. Ce n'est pas quelque chose que vous utilisez vraiment directement dans la programmation de Haskell, et le retour Nonen'est pas du tout le même genre de chose.

La valeur inférieure est la valeur attribuée par la sémantique de Haskell à tout calcul qui ne parvient pas à évaluer normalement une valeur. Un tel moyen qu'un calcul Haskell peut faire est en fait de lever une exception! Donc, si vous essayez d'utiliser ce style en Python, vous devriez en fait simplement lever les exceptions comme d'habitude.

La sémantique de Haskell utilise la valeur inférieure car Haskell est paresseux; vous êtes capable de manipuler des "valeurs" qui sont retournées par des calculs qui n'ont pas encore été exécutés. Vous pouvez les passer à des fonctions, les coller dans des structures de données, etc. Un tel calcul non évalué peut lever une exception ou une boucle pour toujours, mais si nous n'avons jamais réellement besoin d'examiner la valeur, le calcul ne sera jamaisexécutez et rencontrez l'erreur, et notre programme global pourrait réussir à faire quelque chose de bien défini et à terminer. Donc, sans vouloir expliquer ce que signifie le code Haskell en spécifiant le comportement opérationnel exact du programme au moment de l'exécution, nous déclarons plutôt que de tels calculs erronés produisent la valeur inférieure et expliquons ce que cette valeur se comporte; fondamentalement, toute expression qui doit dépendre de toutes les propriétés de la valeur inférieure (autre qu'elle existe) entraînera également la valeur inférieure.

Pour rester «pur», toutes les façons possibles de générer la valeur inférieure doivent être traitées comme équivalentes. Cela inclut la "valeur inférieure" qui représente une boucle infinie. Puisqu'il n'y a aucun moyen de savoir que certaines boucles infinies sont en fait infinies (elles pourraient se terminer si vous les exécutez juste un peu plus longtemps), vous ne pouvez pas examiner aucune propriété d'une valeur inférieure. Vous ne pouvez pas tester si quelque chose est en bas, ne pouvez pas le comparer à autre chose, ne pouvez pas le convertir en chaîne, rien. Tout ce que vous pouvez faire avec un, c'est de le mettre en place (paramètres de fonction, partie d'une structure de données, etc.) intact et non examiné.

Python a déjà ce type de fond; c'est la "valeur" que vous obtenez d'une expression qui lève une exception ou ne se termine pas. Parce que Python est strict plutôt que paresseux, ces "fonds" ne peuvent pas être stockés n'importe où et potentiellement non examinés. Il n'est donc pas vraiment nécessaire d'utiliser le concept de la valeur inférieure pour expliquer comment les calculs qui ne renvoient pas de valeur peuvent toujours être traités comme s'ils avaient une valeur. Mais il n'y a également aucune raison de ne pas penser de cette façon aux exceptions si vous le vouliez.

Le fait de lever des exceptions est en fait considéré comme "pur". C'est intercepter des exceptions qui brise la pureté - précisément parce qu'il vous permet d'inspecter quelque chose sur certaines valeurs inférieures, au lieu de les traiter toutes de manière interchangeable. Dans Haskell, vous ne pouvez attraper que des exceptions dans le IOqui permet une interface impure (donc cela se produit généralement sur une couche assez externe). Python n'applique pas la pureté, mais vous pouvez toujours décider par vous-même quelles fonctions font partie de votre "couche impure externe" plutôt que des fonctions pures, et ne vous autorisez qu'à y détecter des exceptions.

Le retour Noneest complètement différent. Noneest une valeur non inférieure; vous pouvez tester si quelque chose lui est égal et l'appelant de la fonction retournée Nonecontinuera à s'exécuter, en utilisant peut-être de Nonemanière inappropriée.

Donc, si vous envisagez de lever une exception et que vous souhaitez «revenir en bas» pour imiter l'approche de Haskell, vous ne faites rien du tout. Laissez l'exception se propager. C'est exactement ce que les programmeurs Haskell veulent dire quand ils parlent d'une fonction renvoyant une valeur inférieure.

Mais ce n'est pas ce que les programmeurs fonctionnels veulent dire quand ils disent d'éviter les exceptions. Les programmeurs fonctionnels préfèrent les «fonctions totales». Ceux-ci renvoient toujours une valeur non inférieure valide de leur type de retour pour chaque entrée possible. Donc, toute fonction qui peut lever une exception n'est pas une fonction totale.

La raison pour laquelle nous aimons les fonctions totales est qu'elles sont beaucoup plus faciles à traiter comme des "boîtes noires" lorsque nous les combinons et les manipulons. Si j'ai une fonction totale renvoyant quelque chose de type A et une fonction totale qui accepte quelque chose de type A, alors je peux appeler la seconde à la sortie de la première, sans rien savoir de l'implémentation de l'une ou l'autre; Je sais que j'obtiendrai un résultat valide, peu importe la façon dont le code de l'une ou l'autre fonction sera mis à jour à l'avenir (tant que leur totalité est conservée et tant qu'ils conservent la même signature de type). Cette séparation des préoccupations peut être une aide extrêmement puissante pour la refactorisation.

Il est également quelque peu nécessaire pour des fonctions fiables d'ordre supérieur (fonctions qui manipulent d'autres fonctions). Si je veux écrire du code qui reçoit une fonction complètement arbitraire (avec une interface connue) comme paramètre, je dois le traiter comme une boîte noire parce que je n'ai aucun moyen de savoir quelles entrées peuvent déclencher une erreur. Si on me donne une fonction totale, aucune entrée ne provoquera une erreur. De même, l'appelant de ma fonction d'ordre supérieur ne saura pas exactement quels arguments j'utilise pour appeler la fonction qu'il me passe (sauf s'il veut dépendre de mes détails d'implémentation), donc passer une fonction totale signifie qu'il n'a pas à s'inquiéter ce que j'en fais.

Ainsi, un programmeur fonctionnel qui vous conseille d'éviter les exceptions préférerait que vous retourniez plutôt une valeur qui code l'erreur ou une valeur valide, et exige que pour l'utiliser, vous êtes prêt à gérer les deux possibilités. Des choses comme Eithertypes ou Maybe/ Optiontypes sont quelques-unes des approches les plus simples pour le faire dans des langages plus fortement typés (généralement utilisés avec une syntaxe spéciale ou des fonctions d'ordre supérieur pour aider à coller ensemble les choses qui ont besoin d'une Aavec les choses qui produisent une Maybe<A>).

Une fonction qui retourne None(si une erreur s'est produite) ou une certaine valeur (s'il n'y a pas eu d'erreur) ne suit aucune des stratégies ci-dessus.

En Python avec typage canard, le style Soit / Peut-être n'est pas beaucoup utilisé, laissant plutôt des exceptions levées, avec des tests pour valider que le code fonctionne plutôt que de faire confiance aux fonctions pour être totales et automatiquement combinables en fonction de leurs types. Python n'a aucune facilité pour faire appliquer ce code utilise des choses comme les types peut-être correctement; même si vous l'utilisiez comme une question de discipline, vous avez besoin de tests pour exercer réellement votre code pour le valider. Ainsi, l'approche exception / bas est probablement plus adaptée à la programmation fonctionnelle pure en Python.

Ben
la source
1
+1 Réponse impressionnante! Et très complet également.
Andres F.
6

Tant qu'il n'y a pas d'effets secondaires visibles de l'extérieur et que la valeur de retour dépend exclusivement des entrées, la fonction est pure, même si elle fait des choses plutôt impures en interne.

Cela dépend donc vraiment de ce qui peut provoquer le déclenchement d'exceptions. Si vous essayez d'ouvrir un fichier avec un chemin d'accès, alors non, ce n'est pas pur, car le fichier peut ou non exister, ce qui entraînerait une variation de la valeur de retour pour la même entrée.

D'un autre côté, si vous essayez d'analyser un entier à partir d'une chaîne donnée et de lever une exception s'il échoue, cela pourrait être pur, tant qu'aucune exception ne peut sortir de votre fonction.

De plus, les langages fonctionnels ont tendance à renvoyer le type d' unité uniquement s'il n'y a qu'une seule condition d'erreur possible. S'il y a plusieurs erreurs possibles, elles ont tendance à renvoyer un type d'erreur avec des informations sur l'erreur.

8bittree
la source
Vous ne pouvez pas retourner le type inférieur.
gardenhead
1
@gardenhead Vous avez raison, je pensais au type d'unité. Fixé.
8bittree
Dans le cas de votre premier exemple, le système de fichiers et quels fichiers qui s'y trouvent n'est-il pas simplement une des entrées de la fonction?
Vality
2
@Vaility En réalité, vous avez probablement une poignée ou un chemin d'accès au fichier. Si la fonction fest pure, vous vous attendez f("/path/to/file")à toujours renvoyer la même valeur. Que se passe-t-il si le fichier réel est supprimé ou modifié entre deux appels à f?
Andres F.
2
@Vaility La fonction peut être pure si, au lieu d'une poignée du fichier, vous lui passez le contenu réel (c'est-à-dire l'instantané exact des octets) du fichier, auquel cas vous pouvez garantir que si le même contenu entre, la même sortie sort. Mais accéder à un fichier n'est pas comme ça :)
Andres F.