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.

16 commentaires sur “Haskell, demander l’enfer (ou pas)”

  • Belle initiative :) Petite remarque, histoire de pinailler, il vaut mieux éviter d'implémenter show si on n'implémente pas read. Idéalement, si on a show, on a read tel que (read . show == id). Donc il ne faut pas utiliser show comme un .toString(). Il vaut mieux lui donner un autre nom
  • Sinon, il vaut mieux éviter d'utiliser fromJust (même si on passe par isValid). L'idée est de séparer le parsing étape par étape, avec chaque étape qui peut échouer ou pas, puis de combiner les étapes avec (>>=) ou la do-notation. Ainsi on ne prend pas le risque de désynchroniser la partie dangereuse (fromJust,…) de la vérification de validité
  • Merci pour les remarques. Par contre, je n'ai rien compris au 2ème commentaire :)
  • fromJust est ce qu'on appelle une fonction partielle (ie définie partiellement sur son domaine). fromJust n'est *pas* définie sur Nothing de même, [p, b1, c1, b2, c2, a] = ws ne sera définie que si ws a exactement 6 éléments C'est une mauvaise pratique d'utiliser des fonctions partielles, car c'est du code qui même s'il typecheck, ne garantit plus rien. Là, tout le code marche grâce à la ligne "if isValid". En exprimant chaque étape du parsing comme une opération pouvant échouer, par exemple String -> Maybe result, on peut enchaîner tout le traitement avec des (>>=) ou la syntaxe do. On a la même concision, mais beaucoup moins de risques de se tromper. Là où une erreur dans la condition isValid peut faire péter le programme au runtime, une approche avec des fonctions totales garantit que l'on a uniquement des variables bien définies.
  • Une raison particulière pour présenter Haskell plutôt qu'OCaml, langage fonctionnel bien plus utilisé dans l'industrie ? J'aurais tendance à penser qu'Haskell se retrouve aujourd'hui un peu isolé, entre OCaml d'un coté, moins pur, mais plus efficace, plus lisible, plus expressif, et Coq de l'autre coté, beaucoup plus pur, et qui permet d'écrire du code prouvé mathématiquement.
  • @Clément, merci pour les précisions. Comme ce n'était qu'une introduction à Haskell nous n'avons pas voulu rentrer dans des détails trop complexes
  • @Fabrice : J'aurais plusieurs réponses à votre question : - Vous n'étiez pas là - À ma connaissance personne ne connait O'Caml à octo - Le but n'était pas d'utiliser un langage de l'industrie (sinon on aurait utilisé Java), mais d'apprendre un nouveau langage purement fonctionnel
  • @Rémy-Christophe: > Vous n’étiez pas là Je veux bien venir faire une présentation ! > À ma connaissance personne ne connait O’Caml à octo D'où connaissez-vous Haskell ? OCaml est, à ma connaissance, plus enseigné qu'Haskell en France, mais peut-être n'est ce plus vrai ? > apprendre un nouveau langage purement fonctionnel OCaml dispose d'un sous-ensemble purement fonctionnel, aussi expressif qu'Haskell. Et certains milieux industriels en sont très friands (finance, avionique, cloud), alors que l'utilisation d'Haskell dans l'industrie reste anecdotique. Évidemment, rien à voir avec Java...
  • @Clement: Tout à fait d'accord sur la gestion du Nothing, un programmeur expérimenté doit éviter les fonctions partielles. Pour cette première initiation j'ai quand même préféré éviter d'introduire trop de concepts nouveaux. Les monades seront pour une prochaine fois, promis!
  • L'influence de Haskell dans scala (et surtout dans scalaz) est assez visible (typeclasses, first-class support pour les monades, …). L'étude de Haskell est donc très utile pour tout ceux qui veulent faire du fonctionnel sur la JVM. La pureté d'Haskell et sa rigueur en font un assez bon candidat pour débuter la programmation fonctionnelle.
  • @Stéphane pour moi c'est un point fondamental. J'aurais plutôt eu tendance à faire un peu plus lourd en empilant du pattern matching explicite, quitte à montrer une manière plus légère de faire par la suite. C'est l'approche abordée dans Real World Haskell par exemple.
  • > La pureté d’Haskell et sa rigueur en font un assez bon > candidat pour débuter la programmation fonctionnelle. C'est à double tranchant : dans mon expérience, beaucoup d'étudiants sont dégoutés de la programmation fonctionnelle, parce qu'ils la voient comme une alternative à la programmation impérative (on les oblige à faire des récursions au lieu des boucles, etc.), parce qu'on les place dans des langages trop restreints (Haskell, le sous-ensemble purement fonctionnel d'OCaml, etc.) Au contraire, je trouve qu'il est plus intéressant de montrer ce qu'apporte la programmation fonctionnelle quand elle est bien mélangée à d'autres styles (impératifs, objects, etc.), et c'est justement la force d'OCaml, par rapport à Haskell (trop du coté fonctionnel) ou Scala (qui reste trop du coté impératif).
  • @Fabrice, tu me sembles trop peu objectif. Baignant dans les deux mondes depuis quelques années, la dynamique professionnelle côté Haskell me semble meilleure que celle d'OCaml, qui "stagne" un peu ces dernières années. Et j'ai reçu 9 offres d'emploi pour du Haskell en 2012, sans être pourtant Don Stewart ou Johan Tibell. Y'a du boulot dans le Haskell, juste pas vraiment en France (j'espère que ça s'arrangera!).
  • @Alp, peu objectif ? Je ne m'en cache pas, j'ai mis un lien vers la boîte que j'ai créée (OCamlPro), et je suis chercheur à l'INRIA en langages de prog. et systèmes distribués. Mais tu te trompes sur la "dynamique", OCaml a stagné entre 2002 et 2009, mais depuis, plein d'initiatives ont été lancées (OCamlPro et TryOCaml en 2011, OUD et OCamllabs en 2012, OPAM en 2013), et le langage est en pleine révolution (GADT en 2012, typeclasses et namespaces en 2013), et le compilateur beaucoup est plus performant et un runtime multi-coeur devrait aussi sortir en 2013. Bref, OCaml a rattrapé son retard sur la plupart des fonctionalités d'Haskell, et possède plein de fonctionnalités qu'Haskell n'a pas. Un autre avantage d'OCaml, c'est qu'il y a du boulot à l'étranger (Jane Street, Citrix, Acunu, BeSport, Contemplate, Red Lizard, etc.) et en France (Lexifi, OCamlPro, Esterel, etc.). Je dirais que la grande différence est dans les communautés : les anglo-saxons sont beaucoup plus actifs en marketing que les français, et n'hésitent pas à survendre certains aspects d'Haskell (le parallèlisme, par exemple, alors que le Language Shootout montre qu'OCaml sans support multicoeur va plus vite sur un quadcore sur presque toutes les benchmarks). C'est pour ça que j'essaie de sensibiliser les gens qui parlent d'Haskell au fait qu'OCaml est peut-être moins voyant, mais possède les avantages d'Haskell sans beaucoup de ses inconvénients.
  • Votre débat est très intéressant, mais je pense qu'il n'a pas sa place ici. Le but de cet article n'a jamais été de dire qu'Haskell est meilleur que AutreLangageDeProgrammationX. Le but était de faire partager ma découvert d'Haskell lors d'un Dojo réalisé à Octo. Le pourquoi du choix d'Haskell est simple : Nous avons 2 personnes qui connaissent très bien Haskell à Octo. @Fabrice, si vous le souhaitez vous pouvez venir animer un Dojo sur O'Caml quand vous le souhaitez
  • @Remy-Christophe Ma question initiale était juste de savoir comment vous aviez connu Haskell (cours en univ. en France ou à l'étranger, par vous même, etc.). Cela dit, je suis bien-sûr prêt à venir animer un Dojo sur OCaml, quel est le format ?
    1. Laisser un commentaire

      Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *


      Ce formulaire est protégé par Google Recaptcha