Evolution de code en Haskell (partie 2) : évolution

/img/post/Haskell_coding.jpg

Un logiciel n’est jamais terminé. Il ne fini pas d’évoluer pour satisfaire aussi bien de nouveaux besoins que de nouveaux enjeux technologiques. Un logiciel qui ne change pas, qui ne vit pas un refactoring continue, est un logiciel qui se meurt jusqu’à disparaître du marché parce que dépassé.

Nous savons faire évoluer une application écrite selon la POO en jouant sur les propriétés de rétention, de composition, et d’extension des objets, ces entités logicielles qui représentent les concepts adressés par le programme. Mais qu’en est-il en programmation fonctionnel ? Comment peuvent être représentés les concepts ? Comment un code, écrit avec un langage fonctionnel, peut-il évoluer face aux changements ? Je vous propose de montrer ces aspects par un petit tour d’horizon d’un programme écrit en Haskell.

Le programme sur lequel nous allons porter notre attention est un calculateur du résultat d’une bataille pour le jeu King’s Empire. Ce dernier est un jeu MMORPG (Massively Multiplayer Online Role Playing Game) pour smartphone et tablette disponible aussi bien sur iOS que sur Androïd.

Cette seconde partie portera sur les capacités d’évolution du code avec un langage fonctionnel (ici Haskell).

Pour commencer, introduisons une évolution dans le programme sous la forme d’une nouvelle protection des villes : les tours d’archers. Celles-ci sont définies par leur nombre et leur niveau :

data Towers = Towers Int Int
  deriving (Show, Read)

Les tours ne peuvent attaquer mais présentent les mêmes caractéristiques défensives que les troupes. Une première approche serait alors que les tours satisfassent la classe de types Fighter avec 0 comme valeur d’attaque. Or, la tour d’archer est une unité avant tout défensive de la ville et non de combat. Il serait alors intéressant de découper la classe en deux, chacune représentant une caractéristique d’une unité militaire : l’attaque et la défense : Attacker et Defender.

class Attacker a where
  kind   :: a -> TroopClass
  attack :: a -> Int

class Defender a where
  infDef :: a -> Int
  cavDef :: a -> Int
  magDef :: a -> Int
  artDef :: a -> Int

Nos troupes sont à la fois des attaquants et des défenseurs tandis que les tours d’archers ne sont que des défenseurs :

instance Attacker Troop where
 -- etc

instance Defender Troop where
 -- etc

instance Defender Towers where
  infDef (Towers 1 count) = 500 * count
  infDef (Towers 2 count) = 2000 * count
  infDef (Towers 3 count) = 4500 * count
  -- etc

Il reste maintenant à modifier les signatures de nos différentes fonctions de calcul de score :

attackPower :: Attacker a => [a] -> Int
attackPower = foldr (\x y -> attack x + y) 0

defensePower :: (Attacker a, Defender d) => Wall -> [d] -> [a] -> Int
-- etc

battle :: (Attacker a, Defender d) => [a]-> [d] -> Wall -> Int
battle att def wall = (attackPower att) - (defensePower wall def att)

Nous avons effectué ici un simple refactoring.

L’introduction de cette nouvelle unité n’a pas posé de problème et s’est plutôt bien inscrite dans la structure existante, quitte à la raffiner. Maintenant, il a été décidé de rajouter encore une nouvelle unité de protection à la ville, complètement différente cette fois-ci. Il s’agit des protections lourdes aux abords de la ville et qui augmentent, par un pourcentage, la défense globale des troupes dans la ville ; ce pourcentage est fonction du niveau de la protection :

data BindingProtections = BindingProtections Int Int
  deriving (Show, Read)

Cette nouvelle unité, comme pour les murs de la ville, constitue une protection qui influe sur le calcul du score des défenseurs de la ville. Il suffirait alors juste de rajouter ce nouveau type dans la signature de nos fonctions de calcul des points de défense :

defensePower :: (Attacker a, Defender d) => Wall -> [BindingProtections] -> [d] -> [a] -> Int
-- etc

battle :: (Attacker a, Defender d) => [a] -> [d] -> Wall -> [BindingProtections] -> Int
-- etc

L’inconvénient immédiat de cette approche est qu’à chaque introduction d’un nouveau type de protection, la signature de nos fonctions augmenterait jusqu’à ne plus en être lisible. Ceci peut se résoudre de deux manières :

  • utiliser la currification pour éclater notre fonction de calcul de la défense en différentes fonctions et les assembler ensuite dans la fonction de calcul de l’issue du combat,
  • ou définir un nouveau type qui rassemble l’ensemble des protections de la ville influant sur le score des défenseurs et seul ce type changera avec l’ajout de nouvelles protections.

J’ai fais le choix, ici, de la seconde solution :

data TownProtections = TownProtections Wall [BindingProtections]

applyProtections :: TownProtections -> Int -> Int
applyProtections (TownProtections (Wall r) p) defPower = r + (round $ percentOf (foldr (+) 0 $ map (\x -> count x * level x) p)) * defPower
  where
    percentOf n                    = (fromIntegral n :: Float) / 100
    count (BindingProtections c _) = c
    level (BindingProtections _ l) = l

Et il suffit alors de remplacer le type Wall par TownProtections dans la signature des fonctions et utiliser applyProtections dans le corps de celles-ci :

defensePower :: (Attacker a, Defender d) => TownProtections -> [d] -> [a] -> Int
defensePower (TownProtections (Wall r) []) [] [] = r
defensePower prot def att  = applyProtections prot $ (round $ (infantryPercent att) * (infantryPower def) +
  (cavalryPercent att) * (cavalryPower def) + (magePercent att) * (magicPower def) +
  (artilleryPercent att) * (artilleryPower def))
  where
    asFloat          i = fromIntegral i :: Float
    attackingPower     = asFloat $ attackPower att
    cumulAttack        = foldr (\x y -> attack x + y) 0
    percentOf        n = (asFloat n) / attackingPower
    infantryPercent  a = percentOf $ cumulAttack $ filter (\x -> kind x == Infantry) a
    cavalryPercent   a = percentOf $ cumulAttack $ filter (\x -> kind x == Cavalry) a
    magePercent      a = percentOf $ cumulAttack $ filter (\x -> kind x == Mage) a
    artilleryPercent a = percentOf $ cumulAttack $ filter (\x -> kind x == Artillery) a
    infantryPower    a = asFloat $ foldr (\x y -> y + infDef x) 0 a
    cavalryPower     a = asFloat $ foldr (\x y -> y + cavDef x) 0 a
    magicPower       a = asFloat $ foldr (\x y -> y + magDef x) 0 a
    artilleryPower   a = asFloat $ foldr (\x y -> y + artDef x) 0 a

battle :: (Attacker a, Defender d) => [a] -> [d] -> TownProtections -> Int
battle att def prot = (attackPower att) - (defensePower prot def att)

Avec l’introduction du nouveau type et, par conséquent, d’une fonction de calcul de score associée à celui-ci, nous avons réussi à maîtriser l’impact de l’ajout de cette nouvelle demande dans le programme.

Finalement, l’évolution d’un programme écrit avec un langage fonctionnel se fait par l’introduction de nouvelles fonctions qui, par un jeu de composition avec l’existant, permet de maîtriser les modifications du code impacté. Vous aurez aussi constaté que le typage permet aussi de mieux structurer et d’aider à la restructuration du code ; il est un support non seulement pour le développeur dans l’expression de ses idées, mais aussi pour le compilateur dans l’analyse du code (en vue de son optimisation éventuelle) et la détection éventuels de problèmes. De plus, par la nature déclarative des langages fonctionnels, les modifications de l’implémentation sont petites et donc là aussi plus facile à maîtriser.


comments powered by Disqus