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
Line (Point x1 _) _) = x1
getX1 (
resetP1 :: Line -> Line
Line _ p2) = Line (Point 0 0) p2
resetP1 (
moveXs :: Double -> Line -> Line
Line (Point x1 y1) (Point x2 y2)) =
moveXs dx (Line (Point (x1+dx) y1)
Point (x2+dx) y2) (
Les appels de fonctions se font comme habituellement en Haskell :
main :: IO ()
= do
main 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
= _x $ _p1 l getX1 l
On a également une syntaxe pour changer uniquement les membres donnés :
resetP1 :: Line -> Line
= l { _p1 = Point 0 0 } resetP1 l
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 l
p2 = _x p1
x1 = _x p2
x2 in l { _p1 = p1 { _x = x1+dx }
= p2 { _x = x2+dx }
, _p2 }
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)
} 'Point
makeLenses '
data Line = Line
_p1 :: Point
{ _p2 :: Point
,deriving (Eq, Show)
} 'Line
makeLenses '
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
= l ^. p1 . x getX1 l
On peut également affecter une valeur à un membre et retourner la nouvelle valeur du type initial, avec les opérateurs &
et .~
:
resetP1 :: Line -> Line
= l & p1 .~ Point 0 0 resetP1 l
On encore incrémenter des membres successivement :
moveXs :: Double -> Line -> Line
= l & p1 . x +~ dx
moveXs dx l & 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
= g1 & ballPos . _xy .~ V2 x ballY0
g2 in case g^.status of
Running -> g1
Launching -> g2
launchGame :: Game -> Game
= g & status .~ Running
launchGame g & ballPos . _xy .~ V2 (g^.bobPos._x) ballY0
& ballVel . _xy .~ ballVel0
animateGame :: Float -> Game -> Game
= case g^.status of
animateGame dt g Running ->
let objects =
Paddle (_bobPos g) bobRadius
[ WallTop gameYMax
, WallLeft gameXMin
, WallRight gameXMax
,
]= Ball (g ^. ballPos) (g ^. ballVel) ballRadius
b0 = animateBall dt b0 objects
b1 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).