Haskell, demander l'enfer (ou pas)

Régulièrement à Octo, on organise des dojos, des séances d'entrainement pour coder. On a pu réaliser un jeu de la vie en javascript, faire un peu de BDD avec rSpec. Un jour on s'est dit mais pourquoi ne pas essayer un langage purement fonctionnel ? Comme on a plusieurs fan d'Haskell dans nos murs, nous avons choisi celui-ci.

C'est comme ça que nous avons organisé notre premier dojo en Haskell. Et aussi étrange que cela puisse paraître, nous avons réussi à dompter ce langage à la syntaxe et au paradigmes étranges, pour nous développeurs objet.

Regardons plus en détail ce que nous avons fait et appris.

Le but du dojo était de lire un fichier contenant des ordres de virements entre 2 comptes. Il fallait parser chaque ligne ne la traiter que si elle était valide. Chaque ligne devait comporter 6 champs, dans l'ordre :

  • Un numéro sur 2 caractères
  • Le nom de la banque du compte débité, doit commencer par BANQUE
  • Le nom du compte débité, doit commencer par COMPTE
  • Le nom de la banque du compte crédité, doit commencer par BANQUE
  • Le nom du compte crédité, doit commencer par COMPTE
  • Le montant du transfert, un nombre entier

Ex :

01 BANQUE1 COMPTE1 BANQUE2 COMPTE2 120 01 BANQUE2 COMPTE2 BANQUE3 COMPTE3 120 01 BANQUE3 COMPTE3 BANQUE1 COMPTE1 120

Comme c'était une séance de découverte de la syntaxe nous avons fait le choix de ne pas faire de tests unitaires, mais de faire des tests manuels du code. Puis une fois le code produit, nous nous sommes attaqués à écrire les tests unitaires.

Premiers pas en Haskell

Notre première étape a été de décrire la structure Account représentant un compte en banque (nom de la banque, nom du compte). En Haskell, il n'y a pas d'objets. Il n'y a que des structures de données (comme on peut en avoir en C) :

data Account = Account { accountBank :: String, accountName :: String }
  • data est le mot clé pour définir une structure
  • accountBank :: String permet de définir un champs accountBank de type String et aussi de générer l'accesseur au champs accountBank. Contrairement à un langage objet, l'accesseur est global à notre module. Donc attention aux collisions de noms !
  • On répète Account, car à droite du signe = on a le nom de notre type, et à gauche on a le nom de la fonction qui nous permet de construire un type Account

Ensuite on a voulu que notre type Account puisse s'afficher comme on le souhaite dans la console : BANQUE1:COMPTE1. On lui a donc donné le comportement de Show, c'est à dire d'être affichable. On pourrait dire que l'on a étendu notre type Account avec l'interface Show. La majeure différence avec une interface au sens Java est que celle-ci comporte une implémentation par défaut :

instance Show Account where show account = (accountBank account) ++ ":" ++ (accountName account)
  • instance Show Account permet de dire que l'on va "implémenter" le type Show pour Account
  • where show account = explique que l'on va "implémenter" le type Show avec la fonction show qui prend un paramètre account. Haskell ayant un système d'inférence de types, il sait que account est de type Account vu que l'on implémente Show pour le type Account
  • ++ est l'opérateur de concaténation de chaînes

Ensuite, on créé un type Cre qui contient deux Account et la valeur du virement :

data Cre = Cre { creAccountFrom :: Account, creAccountTo :: Account, creAmount :: Int } deriving (Eq, Show)
  • deriving (Eq, Show) est le format court de l'extension de type que l'on a vu plus haut avec instance. Ici on a donc l'implémentation par défaut de Show et de Eq. Eq permet de gérer l'égalité entre deux structures de type Cre.

Ça se complique

Maintenant que l'on sait afficher correctement un Account, on a implémenté les contraintes sur le nom de la banque et du compte. Notre type Account n'étant pas une classe, on ne peut pas lui associer de méthodes, il faut donc créer des fonctions hors de tout contexte :

isValidBankName :: String -> Bool
isValidBankName = startswith "BANQUE"
isValidAccountName :: String -> Bool
isValidAccountName = startswith "COMPTE"
  • isValidBankName :: String -> Bool nous permet de spécifier la signature de la fonction : son nom (isValidBankName) suivit de son type d'entrée (String) et de sortie (Bool)
  • isValidBankName = startswith "BANQUE" définie le corps de la fonction.

C'est ici que commencent les subtilités d'Haskell. Premièrement, on n'utilise pas de parenthèses autour de la liste des arguments d'une méthode, ni de virgule pour les séparer. Seul le caractère espace est utilisé. En Haskell, les parenthèses sont uniquement utilisées pour grouper un appel de fonction avec ses paramètres (ex: maMethode (autreMethode 3) 4). Notre fonction ne prend aucun paramètre pourtant la signature de celle-ci déclare qu'elle prend un type String en entrée. On aurait effectivement pu écrire la fonction comme ceci, en spécifiant le paramètre de la fonction.

  • getInt :: String -> Maybe Int représente toujours la signature de la fonction. Celle-ci ne va pas renvoyer directement un entier, mais un type Maybe paramétré avec Int. Pour résumer on encapsule notre valeur de retour dans un type qui nous permet de traiter les valeurs nulles proprement. Pour plus de détail rendez-vous ici ou ici.
  • getInt string = est la déclaration de la fonction avec son paramètre nommé string
  • if … then … else est un simple if. La seule différence est qu'en Haskell un if est une sorte de fonction qui renvoie donc une valeur.
  • all isDigit string. La librairie standard d'Haskell comporte de nombreuse fonction autour des listes. Et les chaînes de caractères ne sont que des listes de caractères. On vérifie donc que tout (all) les éléments de notre liste (string) répondent bien au prédicat isDigit.
  • read string transforme notre paramètre en entier
  • Just (read string) encapsule notre entier dans une type Maybe. En effet, la fonction read peut ne pas réussir à transformer notre chaine en un entier (ex: "1R")
  • Nothing est le type Maybe quand il est vide
getInt :: String -> Maybe Int
getInt string = if all isDigit string then Just (read string) else Nothing

Quelque part on fait de la duplication de code avec ce paramètre bank. Les langages fonctionnels apportent une réponse avec la curryfication, qui permet de se passer de se paramètre. On transforme la fonction startswith à deux paramètres en une nouvelle fonction (isValidBankName) à un seul paramètre en l'appliquant partiellement avec "BANQUE".

On a aussi besoin de vérifier que le montant du virement est bien un nombre et de le transformer de String en Int. On défini donc une méthode getInt qui d'une String nous renvoie un entier (ou pas) :

isValidBankName bank = startswith "BANQUE" bank

Je suis paresseux

Enfin, on fait la glue entre tout ça dans la méthode getCre qui à partir d'une String nous renvoie (ou non) une opération de virement (un Cre) :

getCre :: String -> Maybe Cre
  getCre string = if isValid then Just cre else Nothing
  where
    isValid = length ws == 6 && isValidAmount && isValidAccounts && isValidPrefix
    ws = splitOn " " string
    [p, b1, c1, b2, c2, a] = ws
    isValidPrefix = p == "01"
    isValidAmount = isJust $ getInt a
    isValidAccounts = all isValidBankName [b1, b2] && all isValidAccountName [c1, c2]
    cre = Cre (Account b1 c1) (Account b2 c2) (fromJust $ getInt a)

Avant de comprendre cette fonction, il faut savoir qu'Haskell est un langage à évaluation paresseuse. Il n'exécute pas de code avant que les résultats de ce code ne soient réellement nécessaires. Par exemple, la variable isValidAmount ne sera évaluée que si length ws == 6 est True.

  • getCre string = … where … définie la fonction getCre qui auront accès aux variables dans le bloc where. Ceci permet donc d'avoir des corps de fonctions purement fonctionnel, sans déclaratif
  • ws = splitOn " " string découpe notre string en tableau de String sur le caractère espace
  • [p, b1, c1, b2, c2, a] = ws ici on fait du pattern matching très léger. Pour simplifier, on nomme explicitement chaque élément de notre tableau ws
  • isJust $ getInt a est l'équivalent à IsJust (getInt a)
  • Account b1 c1 permet de créé une instance d'Account avec b1 comme nom de banque et c1 comme nom de compte

Finalement, on parcourt toute notre chaine d'entrée :

parsedFile = map getCre ls
  where ls = splitOn "\n" inputSample
  • map getCre ls applique la fonction getCre à tous les éléments de la liste ls

Conclusion

Après avoir passé à faire des bouts de code Haskell, on a tous été impressionné par les possibilités de ce langage. Et surtout, j'ai été fasciné par la lisibilité du code final. Qui même si on ne lit pas la syntaxe reste néanmoins compréhensible.

Le code source du dojo est disponible sur github. Le repo contient aussi le code de quelques tests que nous avons codé.

Finalement, Haskell c'est pas bien compliqué. Il suffit d'être bien guidé, merci à Sebastian de l'avoir fait.