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.