Cours 3

Htmligure
Version & licenses
Creative Commons License

Calculer une valeur dépendant d'une liste

Guyslain
Thursday, 21 July 2016

Retour au sommaire.

Réduire une liste à une valeur.

Il nous reste une dernière famille de fonctionnelles de listes : celle qui calcule une seule valeur, qui dépend de chaque élément d'une liste. Typiquement, calculer la somme des valeurs de la liste, calculer l'élément maximum ou minimum d'une liste, etc. La valeur retournée peut-être un type simple, mais peut aussi être une nouvelle liste, une fonction, ou n'importe quel type. L'important est que la valeur obtenue dépend de toute la liste globalement. Cela ne doit pas dépendre d'un seul élément (sinon on cherche cet élément et on calcule avec), et ne doit pas considérer les éléments de façon complètement indépendante les uns des autres (sinon, on peut probablement utiliser map, filter, etc).

Réduire par un opérateur binaire.

La façon la plus simple de comprendre l'opération de réduction intervient dans le cadre de l'utilisation d'opérateurs binaires associatifs. Par exemple, l'addition des entiers, ou la conjonction de booléens. Pour additionner tous les entiers d'une liste, il faut faire :

  1. 3 + 5 + 4 + 7 + 2 + 8

Pour calculer une conjonction de tous les booléens d'une liste, on fait :

  1. true && true && false && true && false && true

On veut donc une opération reduce, dont l'effet est décrit par :

  1. reduce (++) [e1;e2;...;en] = e1 ++ e2 ++ ... ++ en

avec (++) un opérateur binaire. C'est une version simpliste de la fonction générale de réduction. En effet :

  • reduce n'est pas définie pour la liste vide,
  • comment l'expression doit-être parenthésée, par la gauche ou par la droite ? En général, e1 ++ ( e2 ++ ( ... ++ en )..) et (...( e1 ++ e2 ) ++ e3 ++ ... ) ++ en n'ont pas la même valeur, il faut que (++) soit associatif.
  • reduce ne fonctionne que pour un opérateur binaire, prenant deux arguments de même type, et retournant un valeur de même type que les arguments : (++) : 'elt -> 'elt -> 'elt. Par exemple, pour une liste d'entiers, on ne peut obtenir qu'un entier.

Néanmoins, reduce suffit pour certains calculs, en particulier les sommes, les calculs de maximum ou de minimum. On peut donc utiliser :

  1. val Core.Std.List.reduce_exn : 'elt t -> f:('elt -> 'elt -> 'elt) -> 'elt
  2. val Core.Std.List.reduce : 'elt t -> f:('elt -> 'elt -> 'elt) -> 'elt option

  3. let sum_of list = Core.Std.List.reduce_exn ~f:(+)
  4. let res1 = sum_of [3;4;2;5;7;1;6] (* res1 = 28 *)
  5. let will_raise_an_error = sum_of []

La deuxième version gère le cas de la liste vide, en retournant une valeur optionnelle. La première version produira une erreur sur la liste vide, il faut donc s'en méfier.

On peut utiliser reduce pour refaire exists et for_all :

  1. open Core.Std

  2. let exists predicate list =
  3. list
  4. |> List.map ~f:predicate
  5. |> List.reduce ~f:(||)

  6. let for_all predicate list =
  7. list
  8. |> List.map ~f:predicate
  9. |> List.reduce ~f:(&&)

Par contre ces deux versions sont moins efficaces, car elles calculent toujours le prédicat sur tous les éléments de la liste, alors que List.for_all et List.exists s'arrêtent au plus tôt dans leur calcul, dès que le résultat est déterminė (par évaluation paresseuse des opérateurs booléens).

Généralisation.

Attaquons-nous aux trois problèmes :

  • reduce n'est pas défini lorsque la liste est vide. Facile : il suffit de donner un argument supplémentaire à la fonction, représentant la valeur du résultat si la liste est vide. Dans le cas des opérateurs binaires associatifs, il s'agit de l'élément neutre de l'opérateur, $0$ pour l'addition, true pour la conjonction, false pour la disjonction. En général, on peut y penser comme à un élément neutre, ou simplement se souvenir que c'est la valeur réduite de la liste vide.
  • reduce ne précise pas le parenthésage. Facile aussi : il suffit de donner deux versions, une parenthésant par la droite, et l'autre parenthésant par la gauche.
  • reduce fonctionne avec un seul type, l'opérateur servant à réduire doit être de type 'elt -> 'elt -> 'elt, pour une liste de type 'elt list. Celui-ci va demander plus d'effort pour être corrigé. Le point principal est que nous souhaitons que le type du résultat de la réduction puisse être différent du type des éléments de la liste. Notre opérateur doit donc être de type :

    1. 'elt -> 'something -> 'result
    2. (* or *) 'something -> 'elt -> 'result

    avec 'result le type du résultat de la réduction. Par ailleurs, on veut que chaque résultat partiel influe sur le résultat final, donc 'result doit apparaître aussi en argument de l'opérateur. Nous n'avons donc pas le choix, l'opérateur est de type :

    1. 'elt -> 'result -> 'result
    2. (* or *) 'result -> 'elt -> 'result

    Les deux versions vont exister, selon qu'on parenthèse par la droite ou par la gauche. Dans les deux cas (seul l'ordre des arguments change), ce type s'interprète comme un consommateur de 'elt, et possèdant un état de type 'result. On va renommer 'result en 'state du coup.

Voici donc les deux fonctions de réduction. En anglais fold veut dire plier, il s'agit donc de replier la liste sur elle-même pour en faire quelque chose de petit.

  1. val List.fold_left : ('state -> 'elt -> 'state) -> 'state -> 'elt list -> 'state
  2. val List.fold_right : ('elt -> 'state -> 'state) -> 'elt list -> 'state -> 'state

  3. val Core.Std.List.fold :
  4. 'elt t -> init:'state -> f:('state -> 'elt -> 'state) -> 'state
  5. val Core.Std.List.fold_right :
  6. 'elt t -> f:('elt -> 'state -> 'state) -> init:'state -> 'state

Attardons-nous sur List.fold_left. L'usage de cette fonction est

  1. let final_state = List.fold_left ~f:transition ~init:state list

Une première remarque à la vue de cette fonction est qu'elle ressemble beaucoup à l'exécution dans un automate fini : on part d'un état initial, et pour chaque lettre (chaque élément de la liste), on applique la transition, jusqu'à atteindre un état final. C'est une façon tout à fait valide de comprendre cette fonctionnelle, mais cela ne nous donnera pas beaucoup d'intuition sur comment et quand l'utiliser.

Par analogie avec ce qu'on a vu pour reduce, on peut définir fold_left par ces équations :

  1. fold_left [e1;e2;e3;...;en] ~f ~init
  2. (* 1 *) = (f (... (f (f init e1) e2) e3) ... en)
  3. (* 2 *) = let f' elt state = f state elt in
  4. init |> f' e1 |> f' e2 |> f' e3 |> ... |> f' en
  5. (* 3 *) =
  6. let state1 = f init e1 in
  7. let state2 = f state1 e2 in
  8. let state3 = f state2 e3 in
  9. ...
  10. let staten = f staten_1 en in
  11. staten

La première reformulation exprime que fold_left construit une expression en utilisant init, chaque élément de la liste, et la fonction f. En remplaçant f par une opérateur binaire ++ associatif à gauche, on obtient

  1. init ++ e1 ++ e2 ++ e3 ++ ... ++ en

C'est donc bien reduce avec un terme supplémentaire à gauche.

La deuxième et la troisième formulation exprime fold_left comme une séquence d'opérations : en partant de init, appliquer f avec e1, puis le résultat à f avec e2, etc. C'est la vision de f comme un consommateur, qui accumule des résultats intermédiaires à chaque fois qu'il consomme un élément.

Cela suggère que fold_left est la version fonctionnelle du programme suivant :

  1. State state = initialState;
  2. for (Element elt : list)
  3. state = f(state,elt);
  4. return state;

ou avec un code plus idiomatique (f est remplacé par la méthode accept de l'état):

  1. State state = new State();
  2. for (Element elt : list)
  3. state.accept(elt);
  4. return state.getValue();

Il y a encore une autre façon de comprendre fold_left, en considérant son type, et en le parenthésant :

  1. val List.fold_left : ('state -> 'elt -> 'state) -> ('state -> 'elt list -> 'state)

fold_left transforme une fonction sur les 'elt en une fonction sur les 'elt list. Plus précisément, elle transforme une fonction transformant un état en un autre par rapport à un élément, en une fonction transformant un état en un autre par rapport à tous les éléments d'une liste. C'est donc, comme map, un combinateur transformant une fonction binaire en une fonction sur les listes. fold_left étend f en répétant f sur chaque élément.

fold_right fonctionne sur le même principe que fold_left, mais parenthèse à droite. Il lit donc la liste de la droite à la gauche. Ses arguments reflètent aussi cet ordre. En général, en OCaml, fold_left est préféré, ne serait-ce que pour une raison d'efficacité : fold_left est légèrement plus rapide.

Exemples.

fold_left généralise reduce, donc ce que nous avons fait avec reduce peut être refait avec fold_left.

  1. let sum list =
  2. List.fold_left (+) 0 list

  3. let for_all list =
  4. List.fold_left (&&) true list

  5. let exists list =
  6. List.fold_left (||) false list

On peut écrire reduce en utilisant uniquement fold_left.

  1. let reduce list ~f =
  2. if list = [] then None
  3. else Some (List.fold_left ~f ~init:(List.hd_exn list) (List.tl_exn list))

S'il fallait partir sur une île déserte avec une seule fonction, il faudrait prendre fold_left ! On peut écrire toutes les autres fonctions de listes avec. Faisons quelques exemples intéressants. concat étend la concaténation de deux listes, à la concaténation d'une liste de listes, c'est donc l'extension de append aux listes. Petit piège, @ est associatif à droite, il faut donc utiliser fold_right. Pour le reste il suffit de remarquer que l'élément neutre pour la concaténation est la liste vide, ce qui nous donne la valeur initiale.

  1. let concat list_of_lists =
  2. List.fold_right list_of_lists ~f:(@) ~init:[]

On peut refaire map (et bind est similaire). Cette fois, on va plutôt utiliser l'analogie du consommateur qui accumule les résultats intermédiaires. Pour map, on accumule les images des éléments par la fonction. Par ailleurs l'image de la liste vide doit être la liste vide, donc l'état initial est la liste vide.

  1. let map ~f list =
  2. let accumulate accu elt = f elt :: accu in
  3. list
  4. |> List.fold_left ~f:accumulate ~init []
  5. |> List.rev

Notons qu'on accumule les images dans l'ordre inverse (le premier se retrouve dernier), ce qui nous oblige à renverser la liste finale avec List.rev.

Mais bien sûr, on peut faire List.rev avec fold_left :

  1. let rev list =
  2. List.fold_left list ~f:List.cons ~init:[]

  3. (* List.cons = fun elt list -> elt :: list *)

Retour au sommaire.