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 :
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.
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 :
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.
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 ?
La manipulation des données en OCaml se fonde sur ces trois principes :
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.
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) :
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 :
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.
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).
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 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 :
Les deux constructeurs suivants sont plus typiques de la syntaxe générale des constructeurs :
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 :
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 :
Il n'y a donc qu'une seule syntaxe à apprendre : les valeurs se construisent de la même façon qu'elles s'observent.