web-dev-qa-db-fra.com

Gestion des exceptions de style fonctionnel

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. Dans Python (ou d'autres langages qui n'encouragent pas pleinement la programmation fonctionnelle), on peut retourner None (ou une autre alternative traitée comme la valeur inférieure, bien que None ne le fasse pas) pas strictement conforme à la définition) chaque fois que quelque chose ne va pas pour "rester pur", mais pour ce faire, il faut observer une erreur en premier lieu, à savoir.

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 ne l'ai jamais vu faire en Python dans la pratique, j'ai joué avec quand j'ai étudié FP il y a un an. Ici, tout optional- la fonction décorée renvoie des valeurs Optional, qui peuvent être vides, pour les sorties normales ainsi que pour une liste d'exceptions spécifiée (des exceptions non spécifiées peuvent toujours mettre fin à l'exécution). Carry crée une évaluation différée , où chaque étape (appel de fonction retardé) obtient une sortie Optional 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 normal soit Empty. Ici, le try/except le bloc est caché derrière un décorateur, donc les exceptions spécifiées peuvent être considérées comme faisant partie de la signature du 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
24
Eli Korvigo

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 - c'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_thing ne peut jamais retourner, car il devrait renvoyer 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 Bottom qui n'existe pas réellement dans la langue Python. None est un terme impropre; c'est en fait l'unité value, la seule valeur de type d'unité, qui s'appelle NoneType in Python (do type(None) to confirmez par 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 mécanisme d'exception très agréable. Cependant, les exceptions sont utilisées beaucoup plus avec parcimonie en SML que par ex. Python. Comme vous l'avez dit, la manière commune d'indiquer une sorte d'échec dans les langages fonctionnels consiste à renvoyer un type Option. 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 vraiment de types de somme, ce n'est pas une approche viable. Vous pourriez renvoyer None comme un pauvre) type d'option pour signifier l'échec, mais ce n'est vraiment pas mieux que de retourner 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 norme Que cela soit "pur" ou non n'a pas d'importance.

20
gardenhead

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?

11
Robert Harvey

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 retourner None n'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 vraiment besoin d'examiner la valeur, le calcul le fera jamais exécuté et rencontré 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 manières 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 la propriété any 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, de tels "fonds" ne peuvent pas être stockés n'importe où et potentiellement non examinés. Il n'y a donc pas vraiment besoin d'utiliser le concept de la valeur inférieure pour expliquer comment les calculs qui ne pas retourner une valeur peut toujours être traitée comme si elle avait une valeur. Mais il n'y a pas non plus de raison de ne pas penser de cette façon aux exceptions si vous le vouliez.

Le lancement d'exceptions est en fait considéré comme "pur". C'est capture 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 intercepter que des exceptions dans le IO qui 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 attraper des exceptions.

Renvoyer None à la place est complètement différent. None est une valeur non inférieure; vous pouvez tester si quelque chose lui est égal et l'appelant de la fonction qui a renvoyé None continuera de s'exécuter, en utilisant peut-être None de maniè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 lorsqu'ils évitent 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 savoir n'importe quoi sur 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, j'ai pour le traiter comme une boîte noire parce que je n'ont aucun moyen de savoir quelles entrées peuvent déclencher une erreur. Si on me donne une fonction totale, alors 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 les types Either ou les types Maybe/Option 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 des choses qui ont besoin d'un A avec des choses qui produisent un Maybe<A>).

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

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

7
Ben

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 en cas d'échec, cela peut être pur, tant qu'aucune exception ne peut sortir de votre fonction.

Par ailleurs, 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.

6
8bittree