Les égarements d’un dev Scala dans le monde Python

Disclaimer : j’ai découvert le Python il y a un peu moins de 6 mois, j’ai pu bénéficier de l’expertise des membres du pôle Python pour m’aider à monter en compétence, mais je n’ai pas la prétention de me considérer comme expert. De ce fait, toute remarque pour améliorer mes pratiques sont les bienvenues 😊.

Quoi : une présentation des approches / paradigmes que j’ai bien aimés dans l’apprentissage de la programmation fonctionnelle et du Scala et que j’essaie de mettre en place dans ma découverte du Python.

Pour Qui : Pour les devs Python qui sont curieux sur les sujets FP / Craft et aussi pour les devs Scala qui se disent qu’on peut faire que du crade en Python 😉 (ici aucune connaissance des mots qui font peur (monade, etc…) n’est requise)

Comment : En regardant les approches que j’ai apprécié en Scala et en regardant comment j’ai pu les introduire (ou non ^^’) en Python

Ce que j’apprécie dans le monde Scala (entre autres)

  • (1) Que je puisse voir en regardant la signature d’une fonction si elle peut « planter »
  • (2) Que quelqu’un (le compilateur ^^’) fasse le travail de vérification des types pour moi
  • (3) Que je puisse avoir confiance en mon code, que je puisse le refactorer sereinement
  • (4) Que je puisse voir « le flux » de mon programme facilement

Pour appuyer le propos, on va se placer dans le cas d’usage simple suivant :

  • On récupère des données depuis une base de données A
  • On récupère d’autre données depuis un web service B
  • On croise les données entre A et B
  • On transforme les données / effectue des calculs pertinents
  • On stocke le résultat en C

Il y a aussi des choses que j’apprécie beaucoup dans ma découverte du python :

  • Il y a une librairie pour tout ^^’
  • C’est “simple” au premier abord, et on peut s’améliorer ensuite
  • Les ressources quand on rencontre des difficultés sont énormes (il y en a presque trop ^^’)
  • Le style sans accolade 😊

Voir où ça peut planter

En scala, pour permettre (1) de voir rapidement si les fonctions peuvent planter, on construit notre code de manière à ce que les signatures le reflètent, par exemple :

def getDataFromSourceA(params: ParamForSourceA) 
				: Either[DataAError, DatasetFromA] = ???

Ici le Either signifie que la fonction renvoie soit un DatasetFromA, soit une DataAError. Ainsi, en voyant en faisant appel à cette fonction dans la suite de mon code, je serais obligé de traiter le cas où elle renvoie une DatasourceAError.

En Python, pour représenter des erreurs dans le traitement des données, on utilise normalement des exceptions, ce qui donnerait un `raise DataAError()` d’un côté, et un `try: … except: …` au niveau de la méthode appelante. Mais, pour avoir le même type de visibilité en Python, on m’a conseillé l’utilisation de typing avec mypy, qui permet d’avoir des syntaxes équivalentes :

def get_data_from_source_a(params: ParamForSourceA) -> Union[DataAError, DatasetFromA] : ...

Ici le fait qu’une erreur puisse être retourné est explicite et en utilisant Mypy (je suis débutant pour l’instant dans l’outil) on peut s’assurer qu’il n’y a pas de « trou dans la raquette ».

Pour aller plus loin (en scala) : Il existe d’autre composition de type qu’Either, comme les types Try ou Option, qu’on peut utiliser pour mieux représenter le comportement possible de la fonction

Pour aller plus loin en Python : à la relecture par le Pôle Python de cet article, on me dit que ce n’est vraiment pas l’approche usuelle en Python -> normalement on a le Happy Path dans le type et des levés d’exception qui sont attrapées par les fonctions appelantes. Du coup je vais continuer de progresser, c’est cool 😊

Avoir un ami qui corrige pour nous

Pour l’aspect numéro (2), que quelqu’un (le compilateur) fasse le travail pour moi, avec Scala, si en plus c’est utilisé dans un « bon IDE », il est possible d’être très assisté, par exemple pour être sûr que je gère bien toutes les erreurs possibles : ici en scala, Intellij m’indique l’absence d’exhaustivité

Avec des erreurs définies de la sorte :

sealed trait ProgramError
final case class DataAError(message: String) extends ProgramError
final case class DataBError(message: String) extends ProgramError
final case class JoiningError(message: String) extends ProgramError
final case class TargetError(message: String) extends ProgramError
final case class ProcessError(message: String) extends ProgramError

Si je tente de traiter les erreurs de la sorte :

prgmResult match {
  case Right(_) => print("Everything is fine")
  case Left(e: DataBError) => print("Error In DB" + e.message)

L’IDE va m’indiquer la non-exhaustivité de mon traitement :

match may not be exhaustive.
It would fail on the following inputs: 
Left(JoiningError(_)), Left(ProcessError(_)), Left(TargetError(_))

Relativement à la vérification de l’exhaustivité, pour reproduire la même chose en python aujourd’hui, c’est possible, mais ce n’est pas aussi « facile » (l’astuce n’est pas de moi, je l’ai trouvé ici : https://hakibenita.com/python-mypy-exhaustive-checking). Dans cet exemple, j’imagine un système de gestion de l’état du pipeline

class PrgmState(enum.Enum):
    Retrieved = "Date Retrieved from source A and B"
    Joined = "Joining OK"
    Processed = "Processing OK"
    Saved = "Saving OK"
    Error = "Something want wrong"


def assert_never(value: NoReturn) -> NoReturn:
    assert False, f'Unhandled value: {value} ({type(value).__name__})'


def handle_state(e: PrgmState) -> None:
    if e is PrgmState.Retrieved:
        ...
    elif e is PrgmState.Joined:
        ...
    else:
        assert_never(e)

Ensuite Mypy me renvoie l’erreur suivante :

error: Argument 1 to "assert_never" has incompatible type "Union[Literal[PrgmState.Joined], Literal[PrgmState.Processed], Literal[PrgmState.Saved], Literal[PrgmState.Error]]"; expected "NoReturn"

Ça permet de faire le job, m’ais ce n’est pas encore limpide ^^’, mais qui sait, peut-être que bientôt on pourra faire mieux 😊 -> https://www.python.org/dev/peps/pep-0622/

Être serein quand on y revient

Pour l’aspect numéro (3), être serein sur mon code, ça ne vient pas uniquement du Scala, mais plutôt du monde du Craft (Software Craftmanship, ou Artisanat logiciel). Cependant certains aspects de la programmation fonctionnelle sont intégrés dans cette dynamique. Par exemple les bonnes pratiques relatives aux fonctions, qu’elles soient déterministes, totales, et sans effets de bord.

Par déterministe, on entend : pour des mêmes paramètres d’entrée elle retourne toujours les mêmes résultats de sortie.

Dans l’absolu on peut faire sans… mais ça facilite grandement le raisonnement de travailler avec des fonctions déterministes !

Par sans effet de bord : ce n’est pas la définition exacte, mais les deux idées principales sont ;

  • On de modifie pas de « trucs » à l’extérieur de notre fonction
  • C’est de contrôler / concentrer les endroits où on accède à « l’extérieur du programme »

Comme précédemment, dans l’absolu, ce n’est pas requis… mais encore une fois, en appliquant ces pratiques, revenir sur le code sera d’autant plus simple !

Par totale : quelle que soit la donnée dans le domaine du paramètre d’entrée, je dois renvoyer une réponse.

Au moins deux approches peuvent exister pour faire ça ; checking à la construction des instances, ou management des vilains petits canards…

Ces trois approches, en plus de faciliter le raisonnement, facilitent aussi la mise en place de test par la suite 😊

Et rien n’empêche de les mettre en application quand on fait du développement python !

La vue globale qui fait plaisir

Enfin, Pour terminer, sur l’aspect numéro (4), : voir facilement le flux de mon programme, en scala c’est assez simple, avec les for compréhension : on peut avoir le code suivant :

val prgmResult: Either[ProgramError, Unit] = for {
  a         <- getDataFromSourceA("params")
  b         <- getDataFromSourceB("params")
  joined    <- joinDatasets(a, b)
  processed <- processData(joined)
  _         <- saveProcessedDataset(processed)
} yield ()

Je n’ai pas encore trouvé d’équivalent en python, j’ai l’impression que je vais être obligé :

Soit d’utiliser une approche avec des exceptions :

Je redéfinis mes fonctions de la sorte :

def get_data_from_source_a(params: ParamForSourceA) -> DatasetFromA:
    if everything_is_ok(params):
        return "Ok"
    else:
        raise DataAError()

Et voici le programme ensuite, qui offre une vue globale qui « fait plaisir » mais au détriment de la vérification de l’exhaustivité de la gestion d’erreur

def prgm_with_exc():
    try:
        a = get_data_from_source_a()
        ...
      except PrgmError as exc:
        handle_error(exc)

Mais on ne voit pas quelles fonctions lèvent quelles erreurs, et on n’est donc pas assuré de les avoir toutes gérées

Soit d’enchainer les if / else:

def prgm_with_if():
    a = get_data_from_source_a("")
    if isinstance(a, DatasetFromA):
        b = get_data_from_source_b("")
        if isinstance(b, DatasetFromB):
            …
        elif isinstance(a, DataBError):
            ...
    elif isinstance(a, DataAError):
        ...

Mais je trouve ça peu lisible (moche quoi ^^’)

Soit de mettre des return au milieu de mon code

def prgm_with_return():
    a = get_data_from_source_a("")
    if not isinstance(a, DatasetFromA):
        return handle_error(a)
    b = get_data_from_source_b("")
    if not isinstance(b, DatasetFromB):
        return handle_error(b)
    ...

Mais je n’aime pas les return en milieu de code ^^’

Si vous avez des pistes pour faire plus « propre »(je veux dire par la répondre à toutes mes envies) je suis preneur !

Quelques remerciements :

  • Merci aux membres des pôles python et artisanat logiciel pour les relectures et nombreuses corrections, je pense en particulier à Joël Bourgault, Noé Goudian, Xavier Bouvard et Loic Descotte
  • Merci à Kaizen pour la montée en compétence en Scala, grâce notamment à la formation donnée par Loic Descotte, et aussi pour m’avoir permis de participer à la formation dispensée par John A de Goes.