Exemple "d'Haskellisation" de code

Voir aussi : video youtube - code source

Haskell est principalement un langage fonctionnel avec un système de types algébriques mais il propose aussi de nombreuses autres fonctionnalités : classes de types, extensions de langage, etc.

À partir d’un exemple de code en “programmation fonctionnelle basique”, cet article présente quelques améliorations possibles, en utilisant des fonctionnalités classiques en Haskell : évaluation paresseuse, lens, monade State.

Exemple de base

L’exemple de base est un programme d’animation 2D : une balle se déplace en rebondissant contre les bords de la fenêtre et se réinitialise aléatoirement (position et vitesse) quand on appuie sur la touche Enter.

Tout d’abord, on implémente ce programme en “programmation fonctionnelle basique”, avec la bibliothèque gloss (voir le tutoriel : Animations 2D interactives, en Haskell/Gloss)

Types de données

On définit un type Ball pour représenter la balle (position et vitesse) et un type Model pour représenter les données de notre animation (balle courante et générateur aléatoire).

data Ball = Ball 
  { _pos :: V2 Float
  , _vel :: V2 Float
  }

data Model = Model
  { _ball :: Ball
  , _gen :: StdGen
  }

Fonctions de rappel

Pour implémenter une animation avec la bibliothèque gloss, on définit des fonctions des fonctions de rappel, qui seront utilisées ensuite dans la boucle principale. Par exemple, pour gérer l’affichage, on écrit une fonction qui calcule l’image à afficher à partir du modèle courant (notamment la position courante de la balle).

handleDisplay :: Model -> Picture
handleDisplay model = 
  let (V2 x y) = _pos $ _ball model
  in Pictures
      [ translate x y (circleSolid ballRadius)
      , rectangleWire winWidthF winHeightF ]

Pour gérer les événements (clavier, souris…), la fonction de rappel prend un événement et un modèle et retourne le modèle mis à jour. Ici, on ne gère que la touche Enter, où on génère aléatoirement une nouvelle balle.

handleEvent :: Event -> Model -> Model
handleEvent (EventKey (SpecialKey KeyEnter) Up _ _) model =
  let (ball', gen') = random (_gen model)
  in model { _ball = ball', _gen = gen'}
handleEvent _ model = model

Enfin, pour gérer les pas de temps de l’animation, on prend la durée écoulée depuis la dernière mise à jour et le modèle courant et on retourne le nouveau modèle. On peut décomposer le calcul en deux étapes : mouvement de la balle et rebond sur les bords de la fenêtre.

handleTime :: Float -> Model -> Model
handleTime deltaTime model = 
  let ball1 = updateMotion deltaTime $ _ball model
      ball2 = updateBounces ball1
  in model { _ball = ball2 }

updateMotion :: Float -> Ball -> Ball
...

updateBounces :: Ball -> Ball
updateBounces ball0 = ball4
  where
    (V2 px py) = _pos ball0
    (V2 vx vy) = _vel ball0
    ball1 = if xMin >= px
                then Ball (V2 (2*xMin - px) py) (V2 (-vx) vy)
                else ball0
    ...

On notera que Haskell interdit les mutations : on ne modifie pas une variable mais on crée une nouvelle variable contenant les modifications voulues (par exemple : ball1, ball2…).

Programme principal

Enfin, le programme principale se résume à initialiser quelques paramètres (fenêtre, modèle initial…) et à lancer la boucle principale (play) en spécifiant les fonctions de rappel précédentes.

main :: IO ()
main = do
  gen0 <- getStdGen
  let (ball, gen) = random gen0
      model = Model ball gen
      window = InWindow "Animation0" (winWidth, winHeight) (0, 0)
      bgcolor = makeColor 0.4 0.6 1.0 1.0
      fps = 30 
  play window bgcolor fps model handleDisplay handleEvent handleTime

Évaluation paresseuse

Jusqu’ici, pour générer des balles aléatoirement, on stockait le générateur pseudo-aléatoire courant. Comme Haskell fait de l’évaluation paresseuse par défaut, une autre solution est d’utiliser une liste infinie de balles aléatoires.

data Model = Model
  { _ball :: Ball
  , _nextBalls :: [Ball]    
        -- à initialiser avec une liste infinie de balles aléatoires
  }

Ainsi, lorsqu’on veut réinitialiser la balle courante aléatoirement, il suffit de consommer une balle dans la liste infinie.

handleEvent :: Event -> Model -> Model
handleEvent (EventKey (SpecialKey KeyEnter) Up _ _) model = 
  let (ball' : nextBalls') = _nextBalls model
  in model { _ball = ball', _nextBalls = nextBalls'}
handleEvent _ model = model

On n’a donc plus besoin de gérer le générateur aléatoire. Il suffit juste de générer la liste infinie, au début du programme principal.

main :: IO ()
main = do
  (ball0 : balls) <- randoms <$> getStdGen
  let model = Model ball0 balls
      window = InWindow "Animation1" (winWidth, winHeight) (0, 0)
      bgcolor = makeColor 0.4 0.6 1.0 1.0
      fps = 30 
  play window bgcolor fps model handleDisplay handleEvent handleTime

Lens

Comme Haskell interdit les mutations, on a souvent à déconstruire une valeur pour ensuite reconstruire la valeur modifiée (par exemple, la balle dans la fonction updateBounces). Dans le cas de structures de données imbriquées, cela peut vite compliquer le code. Une façon classique d’éviter ce problème en Haskell est d’utiliser un lens (voir le tutoriel : Getters/setters, UFCS et lens (Nim + Haskell)).

Pour cela, il faut tout d’abord définir un lens (une fonction d’accès) sur les types que l’on veut manipuler. Cela peut être fait automatiquement, avec le système de template de Haskell.

makeLenses ''Ball
makeLenses ''Model

On peut alors utiliser les nombreuses fonctions et opérateurs de lens sur ces types. Par exemple, le code suivant retourne la variable model où le champ ball subit une première modification updateMotion deltaTime puis ce même champ subit une seconde modification updateBounces.

handleTime :: Float -> Model -> Model
handleTime deltaTime model = 
  model & ball %~ updateMotion deltaTime
        & ball %~ updateBounces

On peut ainsi accéder ou modifier finement des structures de données imbriquées. Par exemple dans le code suivant, l’expression ball0 & pos . _x .~ 2*xMin - x retourne la variable ball0 où le champ _x du champ pos est initialisé à la valeur donnée.

updateBounces :: Ball -> Ball
updateBounces ball0 = ball4
  where
    (V2 x y) = ball0 ^. pos
    ball1 = if xMin >= x
                then ball0 & pos . _x .~ 2*xMin - x
                           & vel . _x %~ negate
                else ball0
    ...

Monade State

Enfin, pour les calculs de mise à jour de la balle, on peut éviter les variables intermédiaires en utilisant la monade State (voir le tutoriel : Monade State, Transformateur et MTL, en Haskell)

La monade State permet de définir un “contexte” dans lequel chaque action accède ou modifie un état courant. Ainsi, au lieu d’avoir une fonction qui prend une balle et retourne une balle modifiée, on peut écrire un State où l’état est une balle et qui ne retourne rien. En utilisant la notation do et les opérateurs “stateful” de la bibliothèques lens, on peut écrire les actions successives d’accès/modification de la balle courante, de façon plus lisible.

updateBounces :: State Ball ()
updateBounces = do
  (V2 x y) <- use pos
  when (xMin >= x) $ do
    pos . _x .= 2*xMin - x
    vel . _x %= negate
  ...

Il ne reste plus qu’à exécuter l’action State voulue pour obtenir l’équivalent d’une fonction qui prend une balle et retourne la balle mise à jour.

handleTime :: Float -> Model -> Model
handleTime deltaTime model = 
  let update = do updateMotion deltaTime
                  updateBounces
  in model & ball %~ execState update

Conclusion

Si la programmation fonctionnelle “classique” est bien adaptée à certaines applications (par exemple, les interfaces utilisateurs), elle peut parfois nécessiter du code fastidieux à écrire, par exemple pour modifier successivement une donnée ou pour manipuler des structures imbriquées.

Certaines fonctionnalités de Haskell permettent d’éviter ces problèmes. Par exemple, l’évaluation paresseuse permet de consommer une liste infinie de nombres aléatoires sans avoir à gérer un générateur, les lens permettent de manipuler efficacement des structures imbriquées et la monade State de simuler des accès/modifications d’un état courant. Ces outils sont assez classiques en Haskell et peuvent parfois être intéresssants à utiliser, et ils en existent de nombreux autres.