Cours 4

Htmligure
Version & licenses
Creative Commons License

Représentation et utilisation des données.

Guyslain
Thursday, 21 July 2016

Retour au sommaire.

De l'importance de la représentation adéquate des données.

Nous avons déjà beaucoup parlé des types, et pourtant ce 4e cours porte lui aussi intégralement sur les types. Nous allons apprendre à créer de nouveaux types, pour représenter les données que nous souhaitons manipuler.

Parmi tous les usages qu'ont les types pour le programmeur, leur faculté à documenter n'est pas la moindre. Lorsque nous écrivons le type d'une fonction, nous documentons la façon d'utiliser cette fonction. Plus qu'une documentation, c'est même un contrat : une fonction de type int -> int, si on l'applique à un entier, nous donnera nécessairement un entier.

Malheureusement non, c'est un comportement idéal, mais nous avons déjà vu des contre-exemples. Les fonctions pour obtenir la tête et la queue d'une liste en particulier :

  1. val List.hd : 'elt list -> 'elt (* = Core.Std.List.hd_exn *)
  2. val List.tl : 'elt list -> 'elt list (* = Core.Std.tl_exn *)

De fait nous avons dit qu'appliquer une de ces fonctions à la liste vide ne produit pas de résultat. Le rôle de contrat qu'ont les types de ces deux fonctions est donc brisé par ce comportement. Un autre exemple est la division : si le deuxième argument est 0 , le contrat est rompu.

Une raison pour cet échec est que ces deux fonctions sont mal-conçues. La règle est que toute fonction qui ne fait pas ce qu'elle prétend faire est mal-conçue (il existe bien évidemment d'autres raisons pour lesquelles une fonction peut être mal-conçue). Ici nous sommes dans un cas typique d'une fonction qui n'est pas définie pour toutes ces entrées possibles.

  • Soit parce que cette fonction n'est pas complète : on pourrait la définir sur les autres entrées. En général, c'est ou bien une erreur grossière du programmeur, ou bien parce que le type des images de la fonction n'est pas suffisamment riche.
  • Soit parce que cette fonction a un domaine trop grand : elle devrait être définie sur un ensemble plus petit.

Dans le cas de nos fonctions de listes, soit ces fonctions devraient être définies seulement sur les listes non-vides, soit elles devraient avoir dans le type de retour un cas correspondant à l'absence de réponse. De fait, nous avons déjà utilisé à plusieurs reprises des fonctions retournant des valeurs optionnelles. Ainsi on devrait utiliser ces fonctions, qui sont bien conçues :

  1. val Core.Std.List hd : 'elt list -> 'elt option
  2. val Core.Std.List tl : 'elt list -> 'elt list option

Le contrat est ici adéquat et explicite : ces fonctions retournent zéro ou une solution, grâce au type option. (Bien sûr, les développeurs d'OCaml ne sont pas idiots, si les fonctions hd et tl sont codées ainsi, c'est en partie pour des raisons de performance. Parfois prendre de la vitesse exige de prendre des risques.)

Ce que ce petit exemple démontre, c'est l'importance de bien représenter les données que nous souhaitons manipuler. Idéalement, chaque donnée représentée doit avoir un sens, chaque donnée à manipuler doit pouvoir être représentable (et parfois uniquement). L'enjeu est simple : éliminer définitivement les NullPointerException, les Segmentation fault et toutes les erreurs similaires. Rien que ça. En respectant la règle de ne définir que des fonctions totales, nous prenons la garantie que les calculs que nous faisons ne peuvent pas échouer (ils peuvent au pire ne pas terminer).

Pour cela, il nous faut des outils pour représenter les données, et c'est de ça que nous allons parler.

Construction et Observation.

Souvenons-nous qu'en programmation fonctionnelle, une valeur définie ne peut être modifié. Une fois une valeur créée, que peut-on faire avec ?

  • on peut la passer en argument à une fonction, la stocker dans une structure de donnée. Autrement dit, la manipuler comme toute valeur abstraite, en l'abstrayant par divers identifiants (variables, arguments de fonction). Puisqu'elle est manipulée de façon abstraite, cela signifie que la valeur elle-même n'est pas utilisée lors de ce type de manipulation.
  • on peut la consulter, et c'est donc finalement la seule façon de se servir de la valeur pour elle-même, pour ce qu'elle représente. La consulter, c'est récupérer des informations contenues dans la valeur. On parle d'observer la valeur, et plus précisément puisqu'elle n'est jamais modifié, de voir comment cette valeur est construite.

La manipulation des données en OCaml se fonde sur ces trois principes :

  • construire des valeurs,
  • les abstraires,
  • les observer.

Autrement dit : créer, manipuler, utiliser.

Puisque nous avons appris dès le premier cours à abstraire, ce que nous devons maintenant apprendre, c'est construire et observer. Les deux vont ensemble : lorsqu'on souhaite observer une valeur, elle est telle qu'elle a été construite, on l'observe donc de la façon dont elle est construite. Nous devons donc introduire de la syntaxe pour construire des valeurs, et de la syntaxe pour observer les valeurs.

Cette façon de procéder est très différente du paradigme objet. En objet, on crée un objet, puis on interagit avec, par le biais de ses méthodes. Il n'est pas question de savoir comment l'objet a été construit, à partir de quelles valeurs. En fonctionnel, il n'est pas question d'interagir avec une valeur, une valeur est statique, définitive, il n'y a pas d'interaction possible. Cela va donc demander un certain effort d'adaptation.

Observation

Nous avons déjà vu, sans le dire, une syntaxe pour observer des valeurs. Les tuples sont des types construits, à l'aide de parenthèses et de virgules. Une des façons d'observer les deux termes d'un couple, est de répéter la syntaxe de construction, dans l'argument d'une fonction ou dans une déclaration de variable (c'est en fait la même chose) :

  1. let first couple =
  2. let (left,right) = couple in
  3. left

  4. (* Simpler : observe the couple as argument of the function *)
  5. let first (left,right) = left
  6. let second (left,right) = right

Une règle générale est que toute valeur construite peut ainsi être observée à l'endroit où est créée une abstraction. Ici, plutôt que d'utiliser une abstraction couple pour contenir la paire, on observe la paire comme les deux valeurs ayant permis de construire la paire, chacun des termes fait alors l'objet d'une abstraction.

On peut le faire pour toute abstraction ? donc on peut le faire pour left par exemple :

  1. let first_then_first ((left_left,left_right), right) = left_left
  2. let first_then_second ((left_left, left_right), right) left_right

  3. (* when we don't use an abstraction, we can use _ to avoid giving a name *)
  4. let second_then_first (_,(this,_)) = this

Les listes aussi sont des valeurs construites. [] est une valeur construite (à partir de rien), et on peut aussi construire une liste avec le constructeur :: (qui donc n'est finalement pas un opérateur). Il y a donc deux possibilités d'observation pour les listes. Et bien sûr, selon l'observation faite, le calcul risque de ne pas être le même, il nous faut donc une syntaxe pour distinguer chaque cas, et produire des calculs différents dans chaque cas. C'est le rôle du filtrage par motifs, la façon la plus générale d'observer des valeurs.

  1. let is_empty list =
  2. match list with
  3. | [] -> true
  4. | head :: tail -> false

Un filtrage par motifs est introduit par match ... with. Le terme entre les deux mot-clés est la valeur à observer. En général, cette valeur est donnée sous la forme d'une expression quelconque, mais ne doit pas avoir le type d'une fonction : les fonctions ne sont pas construites, on ne peut pas les observer.

Ensuite sont définis des motifs, ce sont les parties entre | et ->. Un motif décrit une forme de construction possible pour la valeur observée. Les barres verticales |introduisant chaque motif sont alignés entre elles, au niveau d'indentation adéquat, pour des raisons de lisibilité. Pour chaque motif, après la flèche, est indiqué le calcul correspondant au cas où la valeur est observable de la façon décrite par le motif.

Dans l'exemple de is_empty, deux motifs sont définis : soit la liste est construite avec [], dans ce cas le résultat de la fonction doit être true, soit la liste est construite avec ::, dans ce cas le résultat doit être faux. Dans le deuxième cas, le motif contient deux abstractions : ce sont deux nouvelles variables, de la même façon que les arguments d'une fonction. Un identifiant pouvant abstraire n'importe quelle valeur, head représente une valeur quelconque, et tailreprésente une liste quelconque (mais en respectant les règles de typage).

Construction

Pour construire une nouvelle valeur, il faut utiliser des constructeurs. Les constructeurs sont des marqueurs syntaxiques qui permettent de repérer et grouper les composants de la valeur construite. On connait déjà quelques constructeurs très spéciaux :

  • [] est le constructeur de la liste vide. Comme il ne prend pas de composante, il est simultanément un constructeur et une valeur, la liste vide.
  • _::_ est le constructeur de liste non-vide. Ce n'est pas une fonction ou un opérateur : il ne provoque pas de calcul, il n'agit pas sur ses deux composantes. elt::list est une nouvelle liste construite à partir des informations elt et list. _::_ n'est pas une valeur, on ne peut par exemple pas le passer en argument à une fonction. Le code suivant n'est donc pas correct :

    1. let identity list = List.fold_right (::) [] list
    2. (* Error: Syntax error: [on (::)] operator expected. *)
  • (_,_) est le constructeur des paires à deux composantes, tout comme _::_ il ne provoque pas de calcul ni d'action, il définit simplement une nouvelle valeur.

Les deux constructeurs suivants sont plus typiques de la syntaxe générale des constructeurs :

  • None est un constructeur d'option. Il n'a pas de composante, donc comme [], il est à la fois constructeur et valeur. On peut donc utiliser None comme n'importe quelle valeur, l'abstraire, le passer en argument, en faire le résultat d'un calcul.
  • Some _ est l'autre constructeur d'option, il a une composante. De nouveau, Some tout seul n'est pas une valeur ou une fonction, ce n'est pas un mot-clé, c'est uniquement un constructeur. Sans composante, on ne peut le manipuler, l'abstraire, etc.

Dans l'exemple des options, les constructeurs sont des identifiants commençant par une majuscule. C'est la règle générale, dont _::_, [] et (_,_) sont des exceptions. Chaque constructeur s'utilise avec un nombre déterminé de composantes, et les composantes doivent vérifier des règles de typages. Par exemple :

  • [] n'a pas de composante,
  • _::_ a deux composantes, la première d'un type quelconque 'elt, la seconde du type 'elt list. On obtient ainsi une valeur de type 'elt list.
  • Some _ a une composante, d'un type 'value arbitraire, ce qui construit une valeur de type 'value option

La symétrie construction/observation.

Il y a une symétrie complète entre la syntaxe permettant de construire les valeurs, et la syntaxe des motifs permettant d'observer les valeurs ! Les motifs précisent différentes méthodes d'assemblage des constructeurs pouvant avoir permis la construction d'une valeur. L'unique différence est dans l'utilisation des identifiants :

  • dans la construction d'une valeur, l'identifiant est une variable dont on utilise la valeur qu'elle représente dans la construction. Ainsi, si list représente la liste [2;3;4], la construction 1::list représente la valeur [1;2;3;4].
  • dans un motif d'observation, l'identifiant est une abstraction, une nouvelle variable qui accueille n'importe quelle valeur pouvant correspondre à son emplacement. Ainsi, dans le motif 1::tail, tail contiendra la queue d'une liste commençant par 1 , si une telle liste se retrouve filtrée. tail représente donc une liste d'entiers indéterminée.

Il n'y a donc qu'une seule syntaxe à apprendre : les valeurs se construisent de la même façon qu'elles s'observent.

Retour au sommaire.