Exercices corrigés 05 : Haskell avancé

Exercices corrigés 05 : Haskell avancé

Une série progressive pour consolider Haskell puis aller plus loin : polymorphisme, contraintes de classes, évaluation paresseuse, types algébriques, récursion structurale, arbres, trie, polynômes et monades appliquées à un entraînement simplifié et à un agent de décision. Essaie chaque question avant d’ouvrir son corrigé.

Questions de cours

Répondez d’abord sans ouvrir les volets de correction.

  1. Donnez les types de map, filter et foldr. Expliquez en quoi un pliage remplace la structure récursive d’une liste.
  2. Quelle contrainte de classe faut-il pour compter les occurrences d’une valeur? Et pour trier une liste? Expliquez les rôles de Eq et Ord.
  3. Quelle différence y a-t-il entre un constructeur de type et une valeur construite? Illustrez avec Maybe a et Just.
  4. Que représentent pure et (>>=) dans une monade? Énoncez les trois lois monadiques.
  5. Que fait la notation do? À quel enchaînement de (>>=) correspond-elle?
  6. Choisissez entre Maybe, State et Writer pour modéliser chacun de ces besoins : calcul susceptible d’échouer, mise à jour de poids, journalisation d’une décision.
  7. Quelle différence pratique y a-t-il entre mapM et mapM_?
  8. Haskell est paresseux. Que signifie cette propriété pour une fonction qui n’utilise pas l’un de ses arguments?
Afficher le corrigé
  1. map :: (a -> b) -> [a] -> [b]; filter :: (a -> Bool) -> [a] -> [a]; foldr :: (a -> b -> b) -> b -> [a] -> b. foldr remplace [] par la valeur initiale et chaque (:) par la fonction de combinaison.
  2. Eq a fournit l’égalité, nécessaire à (==); Ord a fournit l’ordre, nécessaire à comparer les éléments d’un tri.
  3. Maybe est un constructeur de type qui prend un type a et produit Maybe a. Just et Nothing sont des constructeurs de valeurs : Just 3 :: Maybe Int, Nothing :: Maybe a.
  4. pure :: a -> m a place une valeur dans un contexte; (>>=) :: m a -> (a -> m b) -> m b enchaîne deux calculs qui partagent ce contexte. Lois : pure a >>= k = k a; m >>= pure = m; (m >>= k) >>= h = m >>= (\x -> k x >>= h).
  5. Chaque ligne avec une flèche gauche lie le résultat d’un calcul monadique. Par exemple, x <- action devient action >>= (\x -> suite). La dernière ligne est l’expression finale.
  6. Maybe signale une absence ou un échec; State fait évoluer un état; Writer accumule une trace.
  7. mapM exécute une action monadique pour chaque élément et conserve les résultats dans le contexte; mapM_ les ignore et conserve seulement l’effet.
  8. Un argument inutilisé n’a pas besoin d’être évalué. La paresse permet donc, par exemple, de choisir une branche sans calculer l’autre. Haskell utilise l’évaluation paresseuse avec partage (call-by-need).

Exercice 1 — Types, contraintes et évaluation

Considérez ces définitions :

Haskell
absMax [] = -1
absMax (x:xs) = max (abs x) (absMax xs)

combine f g [] = -1
combine f g (x:xs) = g (f x) (combine f g xs)

omega = omega + 1
choose True x y = x
choose False x y = y

e1 = (\x y -> x + y) (1 + 2) (3 + 4)
e2 = (\x y -> y) omega 33
e3 = choose True 0 omega
  1. Donnez les types les plus généraux de absMax et combine, avec leurs contraintes.
  2. Donnez les types de ord et chr, puis expliquez pourquoi count x xs impose une contrainte Eq.
  3. Calculez e1, e2 et e3 avec l’évaluation paresseuse. Que se passerait-il avec une stratégie stricte qui évalue tous les arguments avant l’appel?
Afficher le corrigé

Pour absMax, abs requiert Num a et max requiert Ord a :

Haskell
absMax :: (Num a, Ord a) => [a] -> a

Le cas vide fournit le neutre choisi -1; cette définition n’est pas une valeur absolue maximale conventionnelle pour une liste vide, mais respecte l’énoncé.

Dans combine, f transforme chaque élément en résultat b, et g combine deux résultats. Le littéral -1 impose Num b :

Haskell
combine :: Num b => (a -> b) -> (b -> b -> b) -> [a] -> b

ord :: Char -> Int et chr :: Int -> Char. Une version polymorphe du compteur est :

Haskell
count :: Eq a => a -> [a] -> Int
count _ [] = 0
count target (x:xs) =
  (if target == x then 1 else 0) + count target xs

e1 = 10. Avec la paresse, e2 = 33 et e3 = 0, car omega n’est jamais demandé. Une stratégie stricte tenterait d’évaluer omega avant l’appel et ne terminerait pas pour e2 et e3.

Exercice 2 — Combinateurs de listes

  1. Écrivez mapRec, filterRec et foldrRec par récursion structurale.
  2. Écrivez un compteur polymorphe, une fonction maximumSafe qui renvoie Nothing sur une liste vide, et une fonction reverseFast utilisant foldl'.
  3. Écrivez zipRec, qui associe les éléments par position et s’arrête à la fin de la liste la plus courte.
  4. Définissez repeatApply n f x, puis utilisez-la pour définir la multiplication entière par additions répétées et la puissance pour un exposant naturel.
  5. Définissez isPythagorean et sameSign, en donnant des signatures qui expriment leurs contraintes.
Afficher le corrigé
Haskell
import Data.List (foldl')

mapRec :: (a -> b) -> [a] -> [b]
mapRec _ [] = []
mapRec f (x:xs) = f x : mapRec f xs

filterRec :: (a -> Bool) -> [a] -> [a]
filterRec p = foldr step []
  where
    step x rest
      | p x       = x : rest
      | otherwise = rest

foldrRec :: (a -> b -> b) -> b -> [a] -> b
foldrRec _ z [] = z
foldrRec f z (x:xs) = f x (foldrRec f z xs)

count :: Eq a => a -> [a] -> Int
count target = foldr (\x n -> if x == target then n + 1 else n) 0

maximumSafe :: Ord a => [a] -> Maybe a
maximumSafe = foldr step Nothing
  where
    step x Nothing  = Just x
    step x (Just y) = Just (max x y)

reverseFast :: [a] -> [a]
reverseFast = foldl' (flip (:)) []

zipRec :: [a] -> [b] -> [(a,b)]
zipRec (x:xs) (y:ys) = (x,y) : zipRec xs ys
zipRec _ _ = []

repeatApply :: Int -> (a -> a) -> a -> a
repeatApply n f x
  | n <= 0    = x
  | otherwise = repeatApply (n - 1) f (f x)

multiply :: Int -> Int -> Int
multiply x y
  | y < 0     = negate (multiply x (negate y))
  | otherwise = repeatApply y (+ x) 0

powerNat :: Int -> Int -> Int
powerNat _ n | n < 0 = error "exposant négatif"
powerNat x n = repeatApply n (* x) 1

isPythagorean :: Integral a => a -> a -> a -> Bool
isPythagorean a b c =
  a > 0 && b > 0 && c > 0 && a*a + b*b == c*c

sameSign :: (Num a, Ord a) => a -> a -> Bool
sameSign a b = (a >= 0 && b >= 0) || (a < 0 && b < 0)

Remarques. powerNat suppose un exposant positif ou nul. La fonction renvoie Nothing pour un exposant négatif et Just résultat sinon. Le comptage nécessite Eq, alors que les fonctions arithmétiques nécessitent Num; les comparaisons d’ordre demandent Ord.

Exercice 3 — Modéliser un robot jardinier

Un robot se déplace sur une grille infinie. Il peut transporter des plantes ou des outils, mais pas un robinet. Une case contient un robinet, un objet ou rien. Les plantes sont des légumes, des fleurs colorées ou des arbres fruitiers. Les outils sont des bêches de trois tailles, des pioches ou des arrosoirs vides ou contenant un volume d’eau. Un panier est vide, contient un légume ou deux légumes.

  1. Définissez des types algébriques pour les directions cardinales et relatives, les coordonnées, les plantes, outils, objets, cases, chargement du robot, état du jardin, état du robot et actions.
  2. Écrivez la fonction de rotation de 90 degrés.
  3. Écrivez move, qui déplace une coordonnée d’une distance non négative dans une direction.
  4. Représentez le jardin par une Map et écrivez une fonction pure qui ajoute ou remplace le contenu d’une case.
Afficher le corrigé
Haskell
import qualified Data.Map.Strict as Map

data Cardinal = North | East | South | West
  deriving (Eq, Ord, Show)

data Turn = Left90 | Right90
  deriving (Eq, Show)

data Coord = Coord Int Int
  deriving (Eq, Ord, Show)

turn :: Turn -> Cardinal -> Cardinal
turn Left90  North = West
turn Left90  West  = South
turn Left90  South = East
turn Left90  East  = North
turn Right90 North = East
turn Right90 East  = South
turn Right90 South = West
turn Right90 West  = North

move :: Cardinal -> Int -> Coord -> Coord
move direction distance (Coord x y) =
  let d = max 0 distance
  in case direction of
       North -> Coord x (y + d)
       East  -> Coord (x + d) y
       South -> Coord x (y - d)
       West  -> Coord (x - d) y

data Vegetable = Bean | Carrot | Squash
  deriving (Eq, Ord, Show)

data Colour = Red | Yellow | Blue
  deriving (Eq, Ord, Show)

data Fruit = Apple | Pear | Plum
  deriving (Eq, Ord, Show)

data Plant
  = VegetablePlant Vegetable
  | Flower Colour
  | FruitTree Fruit
  deriving (Eq, Show)

data SpadeSize = Small | Medium | Large
  deriving (Eq, Show)

data Water = EmptyCan | Litres Int
  deriving (Eq, Show)

data Tool = Spade SpadeSize | Pickaxe | WateringCan Water
  deriving (Eq, Show)

data Basket = EmptyBasket | OneVegetable Vegetable | TwoVegetables Vegetable Vegetable
  deriving (Eq, Show)

data Object = PlantObject Plant | ToolObject Tool | BasketObject Basket
  deriving (Eq, Show)

data Cell = Tap | Contains Object | Vacant
  deriving (Eq, Show)

data Load = NothingLoaded | Carrying Object
  deriving (Eq, Show)

type Garden = Map.Map Coord Cell

data Robot = Robot
  { position  :: Coord
  , direction :: Cardinal
  , load      :: Load
  } deriving (Eq, Show)

data Action = MoveBy Int | TurnBy Turn | PickUp | PutDown | WaterBy Int
  deriving (Eq, Show)

place :: Coord -> Cell -> Garden -> Garden
place = Map.insert

Exemples : Flower Red, Spade Large, WateringCan (Litres 3), BasketObject (Two Vegetables Bean Carrot). Le type Load ne possède volontairement pas de constructeur pour Tap : le robot ne peut donc pas transporter un robinet.

Exercice 4 — Plis d’un arbre récursif

On considère un arbre où chaque feuille porte un entier et chaque nœud interne contient une liste ordonnée d’enfants :

Haskell
data Arbre = Feuille Int | Noeud [Arbre]
  deriving (Eq, Show)
  1. Définissez le type d’algèbre et le pli foldArbre.
  2. Définissez des algèbres qui calculent la somme des feuilles, la hauteur (feuille de hauteur 1), le parcours infixe des feuilles et un affichage parenthésé.
  3. Calculez ces résultats pour l’arbre Noeud [Noeud [Feuille 4, Feuille 5], Feuille 2, Feuille 3].
  4. Extension. Ajoutez Implique Formule Formule à un langage de formules booléennes fondé sur les constructeurs Vrai, Faux, Non, Et et Ou. Quelles définitions doivent être modifiées?
Afficher le corrigé
Haskell
import Data.List (intercalate)

data ArbreAlg a = ArbreAlg
  { feuilleAlg :: Int -> a
  , noeudAlg   :: [a] -> a
  }

foldArbre :: ArbreAlg a -> Arbre -> a
foldArbre alg (Feuille n) = feuilleAlg alg n
foldArbre alg (Noeud enfants) =
  noeudAlg alg (map (foldArbre alg) enfants)

sommeAlg :: ArbreAlg Int
sommeAlg = ArbreAlg id sum

hauteurAlg :: ArbreAlg Int
hauteurAlg = ArbreAlg (const 1) (\hs -> 1 + maximum (0 : hs))

listeAlg :: ArbreAlg [Int]
listeAlg = ArbreAlg (:[]) concat

renduAlg :: ArbreAlg String
renduAlg = ArbreAlg show (\xs -> "(" ++ intercalate "," xs ++ ")")

arbreExemple :: Arbre
arbreExemple =
  Noeud [Noeud [Feuille 4, Feuille 5], Feuille 2, Feuille 3]

Résultats : somme 14, hauteur 3, parcours [4,5,2,3]. L’affichage donne une représentation imbriquée qui conserve l’ordre des enfants.

Pour l’extension, il faut ajouter le constructeur au type Formule, au type d’algèbre, au pli, puis à chaque algèbre (évaluation, affichage, etc.). Pour l’évaluation booléenne, implique p q = not p || q.

Exercice 5 — Un dictionnaire préfixé avec un trie

Le dictionnaire contient les mots art, article, artifice, balle, ballon, la, langage et langue. Représentez-le par un arbre de préfixes : chaque arête porte une lettre et chaque nœud indique si un mot se termine à cet endroit.

  1. Définissez un type de trie qui évite d’avoir deux enfants pour la même lettre.
  2. Écrivez l’insertion, la recherche exacte d’un mot et la récupération des mots partageant un préfixe.
  3. Construisez le dictionnaire de l’énoncé et donnez les résultats pour memberWord "art", memberWord "arti" et les mots de préfixe "art".
Afficher le corrigé
Haskell
import qualified Data.Map.Strict as Map
import Data.List (foldl')

data Trie = Trie
  { isWord   :: Bool
  , children :: Map.Map Char Trie
  } deriving (Eq, Show)

emptyTrie :: Trie
emptyTrie = Trie False Map.empty

insertWord :: String -> Trie -> Trie
insertWord [] (Trie _ kids) = Trie True kids
insertWord (c:cs) (Trie end kids) =
  let child = Map.findWithDefault emptyTrie c kids
      child' = insertWord cs child
  in Trie end (Map.insert c child' kids)

memberWord :: String -> Trie -> Bool
memberWord [] (Trie end _) = end
memberWord (c:cs) (Trie _ kids) =
  maybe False (memberWord cs) (Map.lookup c kids)

insertAll :: [String] -> Trie
insertAll = foldl' (flip insertWord) emptyTrie

allWords :: Trie -> [String]
allWords (Trie end kids) =
  (if end then [""] else []) ++
  [ c : suffix
  | (c, child) <- Map.toList kids
  , suffix <- allWords child
  ]

followPrefix :: String -> Trie -> Maybe Trie
followPrefix [] trie = Just trie
followPrefix (c:cs) (Trie _ kids) =
  Map.lookup c kids >>= followPrefix cs

wordsWithPrefix :: String -> Trie -> [String]
wordsWithPrefix prefix trie =
  maybe [] (map (prefix ++) . allWords)
        (followPrefix prefix trie)

dictionary :: Trie
dictionary = insertAll
  ["art", "article", "artifice", "balle", "ballon",
   "la", "langage", "langue"]

memberWord "art" dictionary vaut True; memberWord "arti" dictionary vaut False, car « arti » n’est pas marqué comme mot même s’il est un préfixe. wordsWithPrefix "art" dictionary renvoie ["art","article","artifice"] (l’ordre vient de Map.toList).

Exercice 6 — Polynômes et évaluation par pli

Représentez un polynôme par la liste de ses coefficients, du terme constant vers le degré le plus élevé. Par exemple, Poly [1,2,3] représente 1 + 2x + 3x².

  1. Définissez Poly, son évaluation et sa dérivée.
  2. Expliquez pourquoi la formule d’évaluation par Horner est un pli sur les coefficients.
  3. Calculez le résultat de evalPoly (Poly [1,2,3]) 2.
Afficher le corrigé
Haskell
newtype Poly a = Poly [a]
  deriving (Eq, Show)

evalPoly :: Num a => Poly a -> a -> a
evalPoly (Poly coefficients) x =
  foldr (\coefficient acc -> coefficient + x * acc)
        0 coefficients

differentiate :: Num a => Poly a -> Poly a
differentiate (Poly []) = Poly []
differentiate (Poly (_:coefficients)) =
  Poly (zipWith (\degree coefficient ->
                   fromIntegral degree * coefficient)
                ([1..] :: [Int]) coefficients)

Horner développe c₀ + x(c₁ + x(c₂ + …)), ce qui correspond directement à foldr. Le test vaut 1 + 2·2 + 3·2² = 17. La dérivée de [c₀,c₁,c₂,…] est représentée par [c₁,2c₂,3c₃,…].

Exercice 7 — State : mises à jour séquentielles de poids

Simulez l’entraînement d’un modèle. Chaque taux fourni représente une correction à soustraire à chacun des poids. Écrivez une mise à jour pure, une étape monadique updateStep, puis runOptimization, qui exécute les étapes dans l’ordre.

Test : runOptimization [0.1, 0.05] [1.0, 2.0] doit produire [0.85, 1.85].

Afficher le corrigé
Haskell
import Control.Monad.State.Strict
  (State, execState, get, put)

type Weights = [Double]
type Optimizer a = State Weights a

mockGradientUpdate :: Double -> Weights -> Weights
mockGradientUpdate delta = map (\weight -> weight - delta)

updateStep :: Double -> Optimizer ()
updateStep delta = do
  current <- get
  put (mockGradientUpdate delta current)

runOptimization :: [Double] -> Weights -> Weights
runOptimization deltas initial =
  execState (mapM_ updateStep deltas) initial

La première étape transforme les poids en [0.9,1.9]; la suivante donne [0.85,1.85]. mapM_ enchaîne les actions en conservant l’état final, renvoyé par execState. Il s’agit d’une simulation pédagogique, pas d’un calcul réel de gradient.

Exercice 8 — Writer : journaliser les décisions d’un agent

Un agent attribue une ressource A, B ou C selon trois scores. Il doit renvoyer le choix et enregistrer un message à chaque décision.

  1. Définissez les types ResourceScore, DecisionLog et DecisionAgent.
  2. Écrivez logDecision, allocateResource et sequentialDecisions.
  3. Testez avec les scores (10,5,2) et (3,8,1).
Afficher le corrigé
Haskell
import Control.Monad.Writer.Strict
  (Writer, runWriter, tell)
import Data.List (maximumBy)
import Data.Ord (comparing)

type ResourceScore = (Double, Double, Double)
type DecisionLog = [String]
type DecisionAgent a = Writer DecisionLog a

logDecision :: String -> DecisionAgent ()
logDecision message = tell [message]

allocateResource :: ResourceScore -> DecisionAgent String
allocateResource (scoreA, scoreB, scoreC) = do
  let (name, score) =
        maximumBy (comparing snd)
          [("A", scoreA), ("B", scoreB), ("C", scoreC)]
  logDecision
    ("Allocation faite à la ressource " ++ name
       ++ " avec le score " ++ show score)
  pure name

sequentialDecisions
  :: ResourceScore -> ResourceScore
  -> DecisionAgent (String, String)
sequentialDecisions first second = do
  a <- allocateResource first
  b <- allocateResource second
  pure (a, b)

runDecisions
  :: ResourceScore -> ResourceScore
  -> ((String, String), DecisionLog)
runDecisions first second =
  runWriter (sequentialDecisions first second)

runDecisions (10,5,2) (3,8,1) renvoie (("A","B"), [messageA,messageB]). tell [message] ajoute un élément au journal; la monade Writer concatène les listes dans l’ordre des actions. Les scores d’exemple n’ont pas d’égalité, donc la règle de départage n’intervient pas.

Exercice 9 — Défi avancé : StateT et erreurs

Étendez l’optimiseur pour qu’il refuse un gradient de longueur différente du vecteur de poids. Le calcul doit retourner soit une erreur lisible, soit les poids finaux. Une étape valide applique w - taux × gradient.

  1. Définissez SafeOptimizer comme un StateT sur Either String.
  2. Écrivez applyBatch avec une vérification de longueur.
  3. Écrivez trainSafe et testez un cas valide et un cas invalide.
Afficher le corrigé
Haskell
import Control.Monad.State.Strict
  (StateT, execStateT, get, put)
import Control.Monad.Trans.Class (lift)

type Weights = [Double]
type SafeOptimizer a = StateT Weights (Either String) a

applyBatch :: Double -> [Double] -> SafeOptimizer ()
applyBatch rate gradient = do
  weights <- get
  if length weights /= length gradient
    then lift (Left "Le gradient et les poids n'ont pas la même taille.")
    else put (zipWith (\weight g -> weight - rate * g)
                     weights gradient)

trainSafe
  :: Double -> [[Double]] -> Weights
  -> Either String Weights
trainSafe rate batches initial =
  execStateT (mapM_ (applyBatch rate) batches) initial

trainSafe 0.1 [[0.5,1.0],[1.0,0.0]] [1.0,2.0] donne Right [0.85,1.9]. Avec un gradient de longueur 1, l’étape renvoie Left; la suite de la séquence ne s’exécute pas. StateT s (Either e) combine donc un état et une interruption par erreur.

Retour aux exercices Haskell

Questions / Réponses

Aucune question. Soyez le premier à poser une question.
★ ★ ★ ★ ★
5 votes. Moyenne 5 sur 5.

Commentaires

  • Viviane

    1 Viviane Le 11/03/2023

    Merci beaucoup c'est très facile pour l'apprentissage
  • Junior

    2 Junior Le 08/03/2023

    Satisfaisant
  • nanij

    3 nanij Le 07/03/2023

    Merci pour ce partage de connaissances
  • Franck Duhamel

    4 Franck Duhamel Le 06/03/2023

    Ce site est à recommander. merci
  • steve nn

    5 steve nn Le 06/03/2023

    Vraiment intéressant
  • Ivan

    6 Ivan Le 06/03/2023

    Merci pour vos solutions très intéressantes

Ajouter un commentaire

Anti-spam