Getters/setters, UFCS et lens (Nim + Haskell)

Voir aussi : video youtube - code source

Les langages de programmation proposent différents mécanismes pour accéder/modifier les membres d’une structure de données composée. Cet article présente quelques uns de ces mécanismes, certains très classiques (accès direct, getters/setters) et d’autres plus particuliers (UFCS, lens).

Accès direct et getters/setters (Nim)

Considérons le code suivant, en langage Nim :

type 
  Point = object
    x: float
    y: float
  Line = object
    p1: Point
    p2: Point

func mkLine(x1,y1,x2,y2: float): Line =
  Line(p1: Point(x: x1, y: y1), p2: Point(x: x2, y: y2))

Ce code définit deux types (Point et Line) et un “constructeur” (mkLine). De façon très classique, on peut accéder aux membres avec la notation “point”. Par exemple, l.p2 correspond au membre p2 de la variable l (de type Line) :

func resetP1(l: Line): Line =
  Line(p1: Point(x: 0, y: 0), p2: l.p2)

On peut également chainer les accès :

func getX1(l: Line): float =
  l.p1.x

Ce genre de fonctions est appelé “accesseurs”, notamment dans les langages qui permettent de restreindre l’accès direct aux membres (Java, C#, C++…). De façon similaire, on peut également modifier les membres d’une variable :

func moveXs(l: Line, dx: float): Line =
  var ll = l
  ll.p1.x += dx
  ll.p2.x += dx
  ll

Uniform Function Call Syntax (Nim)

Les fonctions précédentes peuvent être appelées, de façon très classique, en indiquant les valeurs des arguments :

when isMainModule:
  let line = mkLine(1, 2, 3, 4)

  echo getX1(line)
  echo resetP1(line)
  echo moveXs(line, 10)

  echo moveXs(resetP1(line), 10)

On notera cependant que chainer les appels n’est pas très lisible (moveXs(resetP1(line), 10)).

Le langage Nim supporte l’Uniform Function Call Syntax. Cette syntaxe permet d’utiliser la notation “point”, basée sur le premier paramêtre de la fonction.

when isMainModule:
  let line = mkLine(1, 2, 3, 4)

  echo line.getX1()
  echo line.resetP1()
  echo line.moveXs(10)

  echo line.resetP1().moveXs(10)

L’UFCS permet de rendre les chainages plus lisibles (line.resetP1().moveXs(10)).

ADT et pattern matching (Haskell)

En Haskell, on peut définir des types de données sans nommer les “membres” :

import Data.Function

data Point = Point Double Double
    deriving (Eq, Show)

data Line = Line Point Point
    deriving (Eq, Show)

mkLine :: Double -> Double -> Double -> Double -> Line
mkLine x1 y1 x2 y2 = 
    Line (Point x1 y1) (Point x2 y2)

Avec le pattern-matching, on peut “déconstruire” le type pour récupérer la valeur des membres.

getX1 :: Line -> Double
getX1 (Line (Point x1 _) _) = x1

resetP1 :: Line -> Line
resetP1 (Line _ p2) = Line (Point 0 0) p2

moveXs :: Double -> Line -> Line
moveXs dx (Line (Point x1 y1) (Point x2 y2)) = 
    Line (Point (x1+dx) y1) 
         (Point (x2+dx) y2)

Les appels de fonctions se font comme habituellement en Haskell :

main :: IO ()
main = do
    let line = mkLine 1 2 3 4

    print $ getX1 line
    print $ resetP1 line
    print $ moveXs 10 line

    print $ moveXs 10 $ resetP1 line
    print (line & resetP1 & moveXs 10)

On notera qu’on a mis le paramètre de type Line en dernier, ce qui permet de clarifier le chainage d’appels (moveXs 10 $ resetP1 line). Avec l’opérateur &, on peut même inverser le sens de l’évaluation et avoir un code proche du code Nim de la section précédente (line & resetP1 & moveXs 10).

Type enregistrement (Haskell)

Haskell propose également une syntaxe “enregistrement”, permettant de nommer les membres d’un type.

data Point = Point 
    { _x :: Double 
    , _y :: Double
    } deriving (Eq, Show)

data Line = Line 
    { _p1 :: Point 
    , _p2 :: Point
    } deriving (Eq, Show)

mkLine :: Double -> Double -> Double -> Double -> Line
mkLine x1 y1 x2 y2 = 
    Line (Point x1 y1) (Point x2 y2)

Ainsi le nom d’un membre, définit aussi un accesseur, c’est-à-dire une fonction retournant la valeur du membre :

getX1 :: Line -> Double
getX1 l = _x $ _p1 l

On a également une syntaxe pour changer uniquement les membres donnés :

resetP1 :: Line -> Line
resetP1 l = l { _p1 = Point 0 0 }

Cependant, cette syntaxe devient peu pratique quand on veut modifier des types un peu compliqués :

moveXs :: Double -> Line -> Line
moveXs dx l = 
    let p1 = _p1 l
        p2 = _p2 l
        x1 = _x p1
        x2 = _x p2
    in l { _p1 = p1 { _x = x1+dx }
         , _p2 = p2 { _x = x2+dx }
         }

Lens (Haskell)

Introduction

Les lenses sont un mécanisme, classique en Haskell, pour implémenter des accesseurs et des modificateurs. Concrètement, il s’agit de quelques fonctions de base (view, set, over) et d’opérateurs ainsi qu’un système de “templating” permettant de générer automatiquement les fonctions d’accès à un type donné.

Par exemple, avec les types enregistrements précédents, on peut générer les lenses correspondants avec la fonction makeLenses :

{-# Language TemplateHaskell #-}
import Control.Lens
import Data.Function

data Point = Point 
    { _x :: Double 
    , _y :: Double
    } deriving (Eq, Show)
makeLenses ''Point

data Line = Line 
    { _p1 :: Point 
    , _p2 :: Point
    } deriving (Eq, Show)
makeLenses ''Line

mkLine :: Double -> Double -> Double -> Double -> Line
mkLine x1 y1 x2 y2 = 
    Line (Point x1 y1) (Point x2 y2)

On peut alors accéder aux membres de nos types, avec les opérateurs ^. et . :

getX1 :: Line -> Double
getX1 l = l ^. p1 . x

On peut également affecter une valeur à un membre et retourner la nouvelle valeur du type initial, avec les opérateurs & et .~ :

resetP1 :: Line -> Line
resetP1 l = l & p1 .~ Point 0 0

On encore incrémenter des membres successivement :

moveXs :: Double -> Line -> Line
moveXs dx l = l & p1 . x +~ dx
                & p2 . x +~ dx

La bibliothèque de lens propose beaucoup d’autres opérateurs. Ainsi, les lenses nécessitent une phase d’apprentissage et l’extension de langage TemplateHaskell. Les lenses ne sont pas forcément indiqués pour des types simples mais ils peuvent être très pratiques pour des types plus compliqués.

Exemple bong-lens

Dans l’article précédent, on avait passé sous silence certaines fonctions, notamment concernant la gestion du jeu et les calculs géométriques. Voici un exemple d’implémentation avec des lenses :

moveGame :: Float -> Game -> Game
moveGame x g = 
    let g1 = g & bobPos . _x .~ x
        g2 = g1 & ballPos . _xy .~ V2 x ballY0
    in case g^.status of
        Running -> g1
        Launching -> g2

launchGame :: Game -> Game
launchGame g = g & status .~ Running
                 & ballPos . _xy .~ V2 (g^.bobPos._x) ballY0
                 & ballVel . _xy .~ ballVel0

animateGame :: Float -> Game -> Game
animateGame dt g = case g^.status of
    Running -> 
        let objects =
                [ Paddle (_bobPos g) bobRadius
                , WallTop gameYMax
                , WallLeft gameXMin
                , WallRight gameXMax
                ]
            b0 = Ball (g ^. ballPos) (g ^. ballVel) ballRadius
            b1 = animateBall dt b0 objects
        in if b1^.pos._y < gameYMin 
            then g & status .~ Launching 
            else g & ballPos .~ b1^.pos
                   & ballVel .~ b1^.vel
    _ -> g

Pour aller plus loin

Conclusion

En programmation, on modélise généralement le domaine traité avec des structures de données, qu’on peut ensuite manipuler, par exemple directement ou via des accesseurs/modificateurs. Il existe également des mécanismes pour chainer ou combiner des opérations plus complexes (UFCS, lens).

Enfin, on notera que les versions récentes de Haskell proposent également une notation “point” : la Record Dot Syntax (voir la dépêche à propos de GHC 9.2 sur linuxfr.org).