Result¶
Le module fptk.adt.result propose le type Result, conçu pour modéliser les opérations susceptibles de réussir ou d'échouer. Plutôt que de lever des exceptions, Result rend les erreurs explicites, robustes et parfaitement composables.
Concept : La Monade Result (ou Either)¶
En programmation fonctionnelle, Result (souvent appelée Either dans d'autres langages comme Haskell) représente l'issue d'un calcul faillible. Elle se décline en deux cas :
Ok(value): le calcul a réussi et contient une valeur.Err(error): le calcul a échoué et contient une erreur.
Cette approche apporte des avantages majeurs :
- Gestion explicite des erreurs : la signature de la fonction vous avertit qu'un échec est possible.
- Chemins d'erreur composables : enchaînez vos opérations et gérez l'ensemble des erreurs potentielles à la fin de la chaîne.
- Flux de contrôle limpide : fini les exceptions invisibles qui traversent votre pile d'appels.
- Programmation orientée chemin de fer (Railway Oriented Programming) : les succès et les échecs circulent sur des pistes parallèles et clairement définies.
Le problème avec les exceptions¶
def traiter(donnees):
# Quelles exceptions ces fonctions peuvent-elles lever ?
analysees = json.loads(donnees)
validees = valider(analysees)
resultat = transformer(validees)
return resultat
# L'appelant ignore tout des risques potentiels :
try:
res = traiter(donnees)
except json.JSONDecodeError:
# Gère l'erreur d'analyse
except ValidationError:
# Gère l'erreur de validation
# ...
La solution avec Result¶
from fptk.adt.result import Ok, Err
from fptk.core.func import pipe
def traiter(donnees: str) -> Result[Sortie, str]:
return pipe(
donnees,
analyser_json, # Renvoie Result[dict, str]
lambda r: r.bind(valider), # Renvoie Result[Valide, str]
lambda r: r.bind(transformer), # Renvoie Result[Sortie, str]
)
# L'appelant voit le type Result et sait qu'il doit le gérer :
resultat = traiter(donnees)
resultat.match(
ok=lambda s: sauvegarder(s),
err=lambda e: loguer_erreur(e)
)
Le type d'erreur est désormais visible et fait partie intégrante de la chaîne de traitement.
API¶
Types¶
| Type | Description |
|---|---|
Result[T, E] |
Type de base : un succès de type T ou une erreur de type E. |
Ok[T, E] |
Variante représentant un succès. |
Err[T, E] |
Variante représentant un échec. |
Constructeurs¶
Méthodes principales¶
| Méthode | Signature | Description |
|---|---|---|
is_ok() |
() -> bool |
Renvoie True s'il s'agit d'un Ok. |
is_err() |
() -> bool |
Renvoie True s'il s'agit d'un Err. |
map(f) |
(T -> U) -> Result[U, E] |
Transforme la valeur de succès. |
bind(f) |
(T -> Result[U, E]) -> Result[U, E] |
Enchaîne une fonction retournant elle-même un Result. |
flatten() |
Result[Result[T, E], E] -> Result[T, E] |
Déplie un Result imbriqué. |
zip(other) |
(Result[U, E]) -> Result[tuple[T, U], E] |
Combine deux Result en un tuple de valeurs. |
ap(other) |
Result[T -> U, E].ap(Result[T, E]) -> Result[U, E] |
Applique une fonction enveloppée à une valeur enveloppée. |
map_err(f) |
(E -> F) -> Result[T, F] |
Transforme la valeur d'erreur. |
bimap(ok, err) |
(T -> U, E -> F) -> Result[U, F] |
Transforme les deux côtés à la fois. |
recover(f) |
(E -> T) -> Result[T, E] |
Convertit Err en Ok via une fonction. |
recover_with(f) |
(E -> Result[T, E]) -> Result[T, E] |
Convertit Err en un autre Result. |
unwrap_or(default) |
(U) -> T | U |
Récupère la valeur ou une valeur par défaut. |
unwrap_or_else(f) |
(E -> U) -> T | U |
Récupère la valeur ou la calcule depuis l'erreur. |
match(ok, err) |
(T -> U, E -> U) -> U |
Effectue un pattern matching sur les deux cas. |
unwrap() |
() -> T |
Récupère la valeur ou lève une ValueError. |
Fonctionnement technique¶
Structure de données¶
Result est implémentée comme un type scellé avec deux variantes distinctes :
class Result[T, E]:
"""Classe de base - non instanciable directement."""
pass
@dataclass(frozen=True, slots=True)
class Ok[T, E](Result[T, E]):
value: T
@dataclass(frozen=True, slots=True)
class Err[T, E](Result[T, E]):
error: E
Le Bifuncteur : map_err¶
Contrairement au type Option, Result permet de transformer non seulement la valeur de succès (map), mais aussi la valeur d'erreur :
def map_err(self, f):
if isinstance(self, Err):
return Err(f(self.error))
return self # Les instances Ok passent sans modification
Transformer les deux côtés : bimap¶
Lorsque vous devez transformer à la fois la valeur de succès et l'erreur, utilisez bimap pour plus d'efficacité :
result.bimap(
ok=lambda x: x * 2, # Transforme le succès
err=lambda e: f"Erreur: {e}" # Transforme l'erreur
)
# Équivalent à (mais plus efficace que) :
result.map(lambda x: x * 2).map_err(lambda e: f"Erreur: {e}")
Programmation orientée chemin de fer¶
Voyez Result comme une voie ferrée à deux rails :
Piste Ok ─────┬─────┬─────┬─────> Succès
│ │ │
Piste Err ─────┴─────┴─────┴─────> Échec
analyse valide transfo
Chaque étape décide soit de rester sur le rail Ok, soit de bifurquer sur le rail Err. Une fois sur le rail Err, on y reste jusqu'au bout.
Exemples d'utilisation¶
Encapsuler les exceptions existantes¶
from fptk.core.func import try_catch
from fptk.adt.result import Ok, Err
# Encapsulation automatique
safe_parse = try_catch(json.loads)
safe_parse('{"a": 1}') # Ok({"a": 1})
safe_parse('invalide') # Err(JSONDecodeError(...))
# Encapsulation manuelle
def analyser_entier(s: str) -> Result[int, str]:
try:
return Ok(int(s))
except ValueError:
return Err(f"'{s}' n'est pas un entier valide")
Chaînage de validations¶
def valider_age(data: dict) -> Result[dict, str]:
age = data.get("age")
if age is None or age < 0:
return Err("Âge invalide")
return Ok(data)
def traiter_utilisateur(entree: str) -> Result[User, str]:
return (
try_catch(json.loads)(entree)
.map_err(lambda e: f"JSON invalide : {e}")
.bind(valider_age)
.map(lambda d: User(**d))
)
Valeurs de repli intelligentes¶
# Repli simple
valeur = analyser_entier(saisie).unwrap_or(0)
# Repli calculé (ne s'exécute qu'en cas d'erreur)
valeur = analyser_entier(saisie).unwrap_or_else(
lambda err: loguer_et_renvoyer_defaut(err)
)
Récupération d'erreurs¶
Utilisez recover pour convertir un Err en Ok avec une valeur de repli :
from fptk.adt.result import Ok, Err
# Fournir une valeur par défaut en cas d'erreur
Err("introuvable").recover(lambda e: "defaut") # Ok("defaut")
Ok(5).recover(lambda e: 0) # Ok(5) - inchangé
# Exemple pratique : configuration avec repli
def obtenir_config(cle: str) -> Result[str, str]:
return lire_fichier_config(cle).recover(lambda e: config_defaut[cle])
Utilisez recover_with pour une récupération conditionnelle où certaines erreurs peuvent être gérées :
from fptk.adt.result import Ok, Err
def telecharger_avec_retry(url: str) -> Result[Response, str]:
return telecharger(url).recover_with(lambda e:
telecharger(url) if e == "timeout" else Err(e) # Réessayer uniquement les timeouts
)
# Enchaîner plusieurs stratégies de récupération
resultat = (
telecharger_depuis_primaire()
.recover_with(lambda e: telecharger_depuis_secondaire()) # Essayer le backup
.recover(lambda e: reponse_en_cache) # Dernier recours : cache
)
Aplatissement de Results imbriqués¶
Utilisez flatten lorsque vous avez un Result[Result[T, E], E] et souhaitez obtenir un Result[T, E] :
from fptk.adt.result import Ok, Err
# Usage direct
Ok(Ok(42)).flatten() # Ok(42)
Ok(Err("interne")).flatten() # Err("interne")
Err("externe").flatten() # Err("externe")
# Scénario courant : map avec une fonction qui retourne Result
def recuperer_utilisateur(id: int) -> Result[User, str]: ...
def recuperer_permissions(user: User) -> Result[Permissions, str]: ...
# Sans flatten : Result[Result[Permissions, str], str]
imbrique = recuperer_utilisateur(1).map(recuperer_permissions)
# Avec flatten : Result[Permissions, str]
permissions = recuperer_utilisateur(1).map(recuperer_permissions).flatten()
# Note : ceci est équivalent à utiliser bind directement
permissions = recuperer_utilisateur(1).bind(recuperer_permissions)
Application applicative¶
Utilisez ap pour appliquer une fonction enveloppée à une valeur enveloppée :
from fptk.adt.result import Ok, Err
# Usage de base
Ok(lambda x: x + 1).ap(Ok(5)) # Ok(6)
Ok(lambda x: x + 1).ap(Err("oups")) # Err("oups")
Err("pas de func").ap(Ok(5)) # Err("pas de func")
# Fonctions curryfiées pour plusieurs arguments
def additionner(a: int):
return lambda b: a + b
Ok(additionner).ap(Ok(1)).ap(Ok(2)) # Ok(3)
# L'erreur à n'importe quelle étape se propage (première erreur gagne)
Ok(additionner).ap(Err("e1")).ap(Ok(2)) # Err("e1")
Ok(additionner).ap(Ok(1)).ap(Err("e2")) # Err("e2")
# Exemple pratique : combiner des entrées validées
def creer_utilisateur(nom: str):
return lambda email: {"nom": nom, "email": email}
utilisateur = Ok(creer_utilisateur).ap(valider_nom(nom)).ap(valider_email(email))
# Ok({...}) si les deux sont valides, sinon première Err
Quand utiliser Result ?¶
Privilégiez Result lorsque :
- Une opération peut échouer et que l'échec doit être traité.
- Vous souhaitez des erreurs typées et riches plutôt que de simples exceptions textuelles.
- Vous construisez des pipelines où les erreurs doivent se propager naturellement.
- Vous voulez forcer l'appelant à prendre en compte la possibilité d'un échec.
Évitez Result lorsque :
- L'échec est véritablement exceptionnel (bug logiciel critique, mémoire saturée).
- Vous êtes dans une boucle de calcul intensive où la performance est la priorité absolue.
- L'échec ne comporte aucune information utile → considérez alors
Option.
Comparaison avec Option¶
| Aspect | Option | Result |
|---|---|---|
| Cas possibles | Some(T), Nothing |
Ok(T), Err(E) |
| Info d'échec | Aucune. | Oui (via le type d'erreur). |
| Usage type | Valeur potentiellement absente. | Opération potentiellement en échec. |
Voir aussi¶
Option— Lorsque l'absence de valeur ne nécessite pas d'explication.try_catch— Pour convertir les exceptions en objetsResult.validate_all— Pour accumuler plusieurs erreurs au lieu de s'arrêter à la première.traverse_result— Pour collecter plusieursResulten un seul.