• [^] # Re: Lisibilité

    Posté par . En réponse au journal Portage de TapTempo en OCaml. Évalué à 5. Dernière modification le 13 mars 2018 à 17:49.

    Comme tu es plus habitué au paradigme impératif, je donne ci-dessous la boucle while équivalente à mon code avec une fonction récursive, puis je donne une explication pour passer de l'un à l'autre.

    (*
     valeurs impératives modifiées pendant la boucle,
     on les définit à l'éxtérieur avant de rentrer dedans
    *)
    let samples = Sample.create sample_size in
    let stamp = ref (Mtime_clock.now ()) in
    let key_pressed = ref (try input_char stdin with End_of_file -> 'q') in
    (* on rentre dans la boucle *)
    while !key_pressed <> 'q' do
     (* on ne fait quelque chose qu'en cas d'appui sur 'enter' *)
     if !key_pressed = '\n' then begin
     let new_stamp = Mtime_clock.now () in
     let elapsed = Mtime.(Span.to_s (span !stamp new_stamp)) in
     (* logique de mise à jour de la variable `samples` *)
     if elapsed > reset_delay then Sample.reset samples;
     Sample.add new_stamp samples;
     (* on affiche le message adapté *)
     show_bpm samples;
     (* mise à jour de la variable `stamp` *)
     stamp := new_stamp;
     end;
     (* mise à jour de la variable `key_pressed` *)
     key_pressed := try input_char stdin with End_of_file -> 'q'
    done;

    Le principe d'une boucle while, comme illustré dans cet exemple, est d'être une procédure qui modifie par effet de bords certaines variables qui lui sont globales (ici essentiellement samples et stamp) tant qu'une condition est satisfaite (ici, tant que l'on n'a pas appuyé sur q ou que l'entrée standard est n'est pas au bout). En tant que telle, du point de vue d'OCaml, c'est une expression qui a une valeur (comme n'importe quelle expression du langage) de type unit : ce type ne contient qu'une seule valeur (on parle de type singleton) notée (), c'est la valeur qui ne contient aucune information.

    Pour transformer cette boucle en une fonction récursive, il faut d'abord qu'elle retourne la même valeur, à savoir (), lorsqu'elle termine. Ensuite, les variables globales sur laquelle opérait la boucle sont transformées en paramètres de la fonction : elles ne seront plus globales à la boucle, mais locales. On commence donc par écrire :

    let rec loop stamp samples =

    Ensuite, il faut exprimer la condition d'arrêt de la boucle. Celle-ci dépend du caractère que l'on lit sur l'entrée standard, d'où :

    let rec loop stamp samples = match input_char stdin with

    On fait alors une étude de cas, à la manière d'un switch, en commençant par dire quand la fonction termine (et qui renvoie alors ()) :

    let rec loop stamp samples = match input_char stdin with
    (* cas de fin de boucle *)
    | exception End_of_file
    | 'q' -> ()

    Vient ensuite le cœur de la logique de la boucle, ce qui se passe quand on a appuyé sur enter :

    let rec loop stamp samples = match input_char stdin with
    (* cas de fin de boucle *)
    | exception End_of_file
    | 'q' -> ()
    (* on a pressé 'enter' *)
    | '\n' ->
     (* on remet la même logique qu'avec la boucle while *)
     let new_stamp = Mtime_clock.now () in
     let elapsed = Mtime.(Span.to_s (span !stamp new_stamp)) in
     (* logique de mise à jour de la variable `samples` *)
     if elapsed > reset_delay then Sample.reset samples;
     Sample.add new_stamp samples;
     (* on affiche le message adapté *)
     show_bpm samples;
     (* ici on ne met pas à jour la variable stamp mais
     on relance la boucle avec les nouveux paramètres *)
     loop new_stamp samples

    Il reste enfin à traiter le cas où l'on a appuyé sur une autre touche : on ne fait rien et on relance la boucle avec les mêmes paramètres

    let rec loop stamp samples = match input_char stdin with
    (* je ne réécris pas la gestion des autres cas *)
    (* cas par défaut : on boucle sans rien changer *)
    | _ -> loop stamp samples

    Maintenant que le corps de la fonction est écrit, il ne reste plus qu'à l'appeler pour lancer la boucle. Pour ce faire, on appelle la fonction avec, pour paramètres, les valeurs initiales des variables globales de la boucle while :

    let rec loop stamp samples = match input_char stdin with
    (* 
     bla bla
     bla bla
    *)
    in loop (Mtime_clock.now ()) (Sample.create sample_size)

    Voilà le principe général pour transformer une boucle while en fonction récursive : on transforme les variables globales de la boucle en variables locales, et à chaque tour on lui passe les nouvelles valeurs.

    Ceci étant, ce genre d'approche n'est pas propre au paradigme fonctionnel, mais ce dernier en fait un usage omniprésent et c'est la route vers la pureté (absence d'effets de bords)1 . Pour l'instant, la variable samples fonctionne toujours pas effet de bords et, dans le message précédent, j'ai juste modifié l'écriture de sa logique de mise à jour en utilisant une approche par pipe avec le code :

    samples
    |- (if elapsed > reset_delay then Sample.reset else ignore)
    |- Sample.add new_stamp
    |- show_bpm
    |> loop new_stamp

    Mais si, à la place d'une structure impérative, j'utilisais une structure purement applicative, j'aurais juste à changer les deux premiers combinateurs de tuyaux : un pipe |> au lieu d'un T |- et utiliser la fonction identity au lieu de ignore

    samples
    |> (if elapsed > reset_delay then Sample.reset else identity)
    |> Sample.add new_stamp
    |- show_bpm (* ici on log donc on utilise toujours le tee *)
    |> loop new_stamp

    Derrière cette vision par pipeline, il y a une notion élémentaire de mathématique (bon c'est pas du niveau primaire, mais début de lycée ;-), à savoir la composition de fonction.

    composition

    que l'on peut écrire (la pipeline est assez visible sur le diagramme) :

    fun x -> x |> f |> g |> h

    L'opérateur de composition est une opérateur d'ordre supérieur : il prend deux fonctions en entrée et en renvoie une en sortie; raison pour laquelle il a une place centrale de le paradigme de la programmation fonctionnelle. Que peux-t-on faire avec une fonction ?

    • les utiliser, ça on le fait dans tous les langages de la même façon ;
    • les composer, ça c'est plus simple à écrire dans un langage fonctionnel.

    J'espère que ces explications t'aideront un peu mieux à comprendre certains principes élémentaires à la base du paradigme fonctionnel.


    1. En réalité, c'est cette recherche d'absence d'effets de bord qui nous les fait écrire ainsi. On obtient alors des fonctions récursives avec appel dits terminaux, que le compilateur optimisera comme une simple boucle (il fera la traduction dans le sens inverse de celle que je viens de faire). Voir mon commentaire sur la version de taptempo en Emacs Lisp.

    Sapere aude ! Aie le courage de te servir de ton propre entendement. Voilà la devise des Lumières.