Les 3 dernières séances de TP, chacune durant 4h, consisteront en l'écriture d'un petit langage fonctionnel avec son parseur et son interprèteur. Le projet comporte plusieurs parties :
Les deux premiers points sont prioritaires. Le typage et le parseur sont à faire selon le temps dont vous disposez.
Le projet comptera pour 6 points dans la note finale. Il est à faire en binôme et devra être remis sous Ametice, accompagné d'un petit rapport décrivant ce que vous avez eu le temps ou pas de faire. La date limite de rendu est fixée au 11 avril.
Vous sont fournis :
Une fois codés les modules Term et Parse, vous pourrez compiler le programme read_and_write et l'exécuter, en tapant dans un terminal :
avec fichier_source un fichier que vous créerez contenant du code source tel que décrit ci-dessous.
La grammaire du langage est donnée par :
La sémantique est celle qu'on peut attendre des constructions utilisées.
Les termes du langage seront encodées en utilisant les types suivants :
On commence donc par déclarer des types pour les opérateurs, les litéraux et les variables. Ensuite, un terme se construit selon différentes règles (point fixe, abstraction, … ), formant un arbre. En chaque nœud, on place un label d'un type paramètré. Cela servira par exemple pour y indiquer des informations pendant les phases de parsing ou de typage.
Ces déclarations devront figurer dans un fichier term.ml et dans son interface (le type term ne sera pas masqué).
Le fichier term.mli (dont une partie est donnée plus haut) devra aussi contenir des constructeurs de termes. Chacun prend en premier argument le label qui sera placé en racine du terme construit.
Le module Term pourra aussi contenir des prédicats ou des fonctions sur les opérateurs, au besoin.
Enfin vous donnerez une fonction pour écrire un terme sous la forme d'une chaîne de caractères. Vous essayerez autant que possible d'avoir une sortie lisible (lignes courtes, code indenté).
Cette fonction utilise des arguments optionnels. De plus, si un terme a pour label une chaine de caractères, et que le flag verbose_flag est à false, plutôt qu'écrire la chaîne correspondant à l'encodage du terme, on écrira seulement son label (voir les exemples en bas de page).
L'évaluation des termes sera géré par un module qui formera un fichier eval.ml.
Comme appris pendant le cours 2 , l'évaluation des termes se fait par substitution. Ceci fonctionne pour les application dont le membre gauche est directement une abstraction.
Il faut rajouter d'autres règles pour les autres constructions : conditionnelles, opérateurs, points fixes.
Si les deux fils d'un opérateur binaire sont des litéraux, on le simplifie selon les règles pour en faire à son tour un litéral.
On procède de même pour les opérateurs unaires.
Si la condition d'une conditionnelle est un litéral booléen, on la simplifie en gardant seulement son deuxième membre (s'il le litéral est true) ou son troisième membre (s'il est false).
On propose d'écrire une fonction prenant un terme, et calculant une simplification de ce terme telle que plus aucune simplification ne soit possible. Cette fonction sera appelée systématiquement sur le résultat d'un terme.
La règle de point fixe nous permettra de coder des fonctions récursives. Pour simplifier un terme de point fixe, notons-le $\text{Fix}~e$, on utilise la règle suivante :
$$ \text{Fix}~e \longrightarrow e~(\text{Fix}~e)$$
Donc $\text{Fix}~e$ se réduit en l'application de $e$ à $\text{Fix}~e$.
Pour ne pas avoir de boucle infini lors de l'évaluation, il faudra évaluer les points fixes de façon paresseuse obligatoirement.
Une subtilité consiste à comprendre comment encoder le let rec à l'aide de $\text{Fix}$. La règle est la suivante :
Rappelons à cette occasion le codage de la liaison (let) :
La première étape est d'écrire une fonctin réalisant la substitution d'une variable par un terme, dans un terme.
Ensuite, il faudra écrire une fonction effectuant une seule réduction (ou une seule application de la règle de $\text{Fix}$). Afin de ne pas avoir de problème avec le nommage des variables, l'application qu'on choisit de réduire ne doit pas avoir pour ancêtre un nœud abstraction. Vous évaluerez avec une stratégie stricte : on évalue d'abord les deux membres de l'application avant de faire la réduction (avec comme exception le cas du point fixe).
Une fois écrit une fonction effectuant une étape de calcul, on la répète jusqu'à ne plus trouver de réduction.
Pour le typage des termes, nous procédons par étape.
Le contenu de cette section formera un fichier typing.ml, dont un prototype vous est fourni, contenant l'algorithme d'unification.
La grammaire des types de notre langage est très simple :
On utilisera le type suivant pour coder nos types :
On associe à chaque sous-terme un type de la forme Var i, avec des valeurs de i distinctes, et on forme la liste de tous les sous-termes. Si deux variables portant le même nom sont liées à la même abstraction, elle doivent avoir une même valeur pour i. Par contre deux variables portant le même nom, mais non-liées à la même abstraction doivent être distinctes. Pour cela, on peut par exemple commencer par renommer les abstractions avec des identifiants distincts.
Ensuite, une fois calculée la liste des sous-termes, on déduit des contraintes de type pour chaque terme. Une contrainte prend la forme d'une paire de types devant être identifiés. Par exemple, pour le sous-terme (fun y -> y + 2) x : Var 1, avec (fun y -> y + 2) : Var 2 et x : Var 3, on obtient la contrainte :
En examinant le sous-terme fun y -> y + 2 avec y : Var 4 et y + 2 : Var 5, on obtient en plus :
Puis, en examinant y + 2, on a :
Sur cette liste de contraintes que nous obtenons, on lance l'algorithme d'unification. S'il réussit, il suffira de chercher la substitution correspond à la variable de type du terme complet pour obtenir son type.
Les plus avancés pourront tenter de coder eux-même un parseur. Un parseur est fourni, mais vous devez coder le module Parse pour pouvoir l'utiliser.
Voici quelques exemples :
Avec un peu de travail on peut afficher chaque étape du calcul pour obtenir ceci (les accolades dénotant le point fixe d'une fonction) :
La suite de Fibonacci :
Une fonction plus complexe :