Aller au contenu principal

Compilation & langages formels · L3 · Section 7/8

Annales corrigées

Progression

Points d’expérience : XPSérie de jours consécutifs : · —Progression du module : — / —compris

#Annales corrigées — Compilation

Les exercices qui suivent sont tirés des sujets d'examen de l'UE Compilation de L3 Informatique (Université Nice Sophia Antipolis, puis Université Côte d'Azur), dont le cours et les TD sont assurés par Sid Touati. Trois sujets sont archivés localement:

  • Annale_2015-2016.pdf — examen de session 1, mai 2016, 3 h: analyse lexicale (5 pts), analyse sémantique et typage (8 pts), génération de code optimal par Sethi-Ullman (7 pts).
  • Annale_Session2_2015-2016.pdf — examen de rattrapage, juin 2016, 2 h 30: mêmes trois parties, mêmes barèmes.
  • Annale_Session2_2017-2018.pdf — examen de rattrapage, juin 2018, 2 h 30: traduction dirigée par la syntaxe (8 pts), Sethi-Ullman (7 pts), expressions postfixées (5 pts).

Deux consignes reviennent sur tous les sujets: justifiez précisément vos réponses, et seule une feuille A4 manuscrite recto-verso est autorisée. Autrement dit, le cours doit être su, pas feuilleté: les exercices demandent de construire (une description lex, une action sémantique, un code trois adresses), pas de restituer.

Ces sujets ne recoupent pas le corpus OFI du semestre 3, qui porte sur les langages formels, le dénombrement et les récurrences. Les prérequis propres à l'UE Compilation — outil lex, outil yacc, traduction dirigée par la syntaxe, algorithme de Sethi-Ullman — n'apparaissent nulle part ailleurs dans le fonds documentaire.

#Exercice 1 — Lexer un fichier de log HTTP

Source: Annale_2015-2016.pdf, exercice 1 (5 points); Annale_Session2_2015-2016.pdf, exercice 1 (5 points).

On veut collecter des statistiques d'accès aux pages web personnelles des utilisateurs à partir du fichier /var/log/access.log, dont un extrait a la forme suivante:

code

1193.51.25.77 - - [12/Jul/2004:13:44:46 +0200] "GET /~hpc/ HTTP/1.0" 200 3822 "-" "Mozilla/4.73 [en] (X11; I; FreeBSD 3.4-RELEASE i386)"2193.51.25.77 - - [12/Jul/2004:13:47:57 +0200] "GET /~hpc/ HTTP/1.0" 304 - "-" "Mozilla/4.73 [en] (X11; I; FreeBSD 3.4-RELEASE i386)"3193.51.24.15 - - [09/Dec/2004:11:20:27 +0200] "POST /~dupont/manuals/InsertInfo.php HTTP/1.0" 200 2321 "http://vlaminck.labo.univ.fr/~dupont/manuals/InsertInfo.php" "Opera/5.0 (Linux 2.4.2 i386; U) [en]"

Il y a un accès HTTP par ligne. La ligne est composée de: l'adresse IP de la machine qui se connecte; deux entiers positifs ou tirets; la date de connexion; la méthode de connexion HTTP (GET, POST, PUT) suivie de la page accédée et du protocole utilisé (HTTP suivi d'un numéro de version), le tout entre guillemets; deux entiers positifs ou tirets; l'adresse de la page demandée; le nom du navigateur web suivi de sa version.

Les pages personnelles sont toutes de la forme ~login/repertoires, le login étant composé d'au plus 8 lettres.

Question. Écrivez avec l'outil lex un programme qui extrait, pour chaque requête, le login s'il s'agit d'une page personnelle (NULL sinon), le nom du navigateur (sans sa version) et l'adresse IP de la machine. Le programme doit compter et afficher: le nombre d'accès à la page personnelle pour chaque login; le nombre de requêtes par navigateur; le nombre de requêtes provenant de chaque adresse IP. Il est conseillé d'utiliser une ou plusieurs tables de symboles.

Correction détaillée

Méthode. Un analyseur lexical est un automate fini: il n'a aucune mémoire non bornée. Or l'énoncé demande trois comptages indexés par des chaînes arbitraires (login, navigateur, IP). La solution tient donc en deux parties: le lexer reconnaît et extrait les champs (travail fini, faisable par un AFD), et une table de symboles externe fait office de mémoire associative (travail de comptage, hors du modèle fini). C'est exactement la frontière du cours entre reconnaissance et mémorisation.

Pourquoi des conditions de démarrage. Le lexer ne peut pas décider avec une seule règle globale: la ligne contient deux champs entre guillemets (le référent et le navigateur) qui se ressemblent, et le premier doit être ignoré. Il faut donc savoir où l'on en est dans la ligne. Les conditions de démarrage (%x) donnent cet état supplémentaire sans sortir du modèle fini — elles se compilent en dupliquant l'automate par contexte. Ici, cinq états suffisent: INITIAL (avant l'IP), CHAMPS (identité, date, requête), NUM (les deux champs numériques), REFERENT (le champ à ignorer), AGENT (le navigateur).

lexlex

1%{2#include <stdio.h>3#include <stdlib.h>4#include <string.h>5 6/* ---- table de symboles : association chaine -> compteur ---- */7typedef struct { char *cle; int valeur; } entree;8typedef struct { entree *t; int n, cap; } table_t;9 10static void table_init(table_t *T) { T->t = NULL; T->n = 0; T->cap = 0; }11 12/* Renvoie un pointeur sur le compteur associe a `cle`, en le creant si besoin. */13static int *table_acces(table_t *T, const char *cle) {14  int i;

Points de rédaction qui rapportent des points.

  1. Deux champs entre guillemets, un seul à retenir. C'est le piège central de l'exercice. Le référent et le navigateur ont la même forme lexicale (une chaîne entre guillemets), et le référent vient en premier. Une règle unique <CHAMPS>\"{NAV}[^"\n]*\" attraperait le référent "http://vlaminck/" (qui commence par une lettre) et n'atteindrait jamais le navigateur: le compteur nav serait faux, sans aucun message d'erreur. Il faut donc compter les champs: deux champs numériques après la requête, puis le référent, puis le navigateur. C'est à cela que servent les états NUM, REFERENT et AGENT.
  2. Pourquoi une condition de démarrage et pas une règle plus précise. On pourrait écrire une regex qui capture la ligne entière d'un coup. Mais l'énoncé demande de commenter les structures de données et les fonctions utilisées: la découpe en champs par états est ce qui rend le programme lisible et testable champ par champ. C'est aussi le style attendu dans un vrai lexer, où l'on garde un état par contexte syntaxique.
  3. Le nom sans la version. Le séparateur est le / de Mozilla/4.73: on copie jusqu'au premier / (ou espace, ou fin de champ). Tester par préfixe serait faux (Opera et OperaMini sont deux navigateurs distincts).
  4. Extraction du login. L'énoncé impose au plus 8 lettres. La borne k < 8 dans la boucle de copie matérialise cette contrainte; sans elle, un chemin /~abcdefghijkl/ produirait un login de 12 caractères, et la statistique serait indexée sur une clé que l'énoncé n'autorise pas.
  5. Une table de symboles, pas trois compteurs. L'énoncé dit « une ou plusieurs tables de symboles »: la clé est une chaîne quelconque, l'accès doit être dynamique. Une recherche linéaire suffit pour un TP; en production on passerait à une table de hachage, mais l'interface (table_acces renvoyant un int *) ne change pas.
  6. Un détail du fonds local. Dans la copie PDF archivée du sujet, le champ navigateur des lignes 7 et 9 se lit 0pera/5.0 et 0pera/7.0 — avec un zéro initial, et non la lettre O. Si le fichier de log contient réellement ce caractère, la classe [A-Za-z] de la règle NAV ne matche pas le champ, et le navigateur est compté à tort. Il faut décider explicitement: soit le log est propre (Opera), soit la règle doit accepter un premier caractère quelconque hors espace et hors guillemet. Sur un sujet d'examen, signaler ce cas rapporte des points; le passer sous silence le fait perdre.
  7. Compilation. flex access.l && cc lex.yy.c -lfl -o stats && ./stats < /var/log/access.log.

Vérification. Faites tourner le programme sur l'extrait de l'énoncé (9 lignes) et contrôlez chaque table séparément:

  • IP: 193.51.25.77 → 6 (lignes 1 à 6), 193.51.24.15 → 3 (lignes 7 à 9);
  • login (pages personnelles seulement): hpc → 2 (lignes 1 et 2), touati → 1 (ligne 3), dupont → 2 (lignes 7 et 8), samual → 1 (ligne 9);
  • navigateur: Mozilla → 6 (lignes 1, 2, 4, 5, 6 et 8), Opera → 2 (lignes 7 et 9), IE → 1 (ligne 3).

Un écart sur une seule de ces valeurs localise l'erreur. Les deux causes les plus fréquentes: le référent compté comme navigateur (état REFERENT oublié), et un login tronqué par une borne mal placée. Le total des trois tables navigateur doit faire 9, comme celui des IP.

#Exercice 2 — Représenter les types d'un mini-C

Source: Annale_2015-2016.pdf, question 2.1 (1,5 pt); Annale_Session2_2015-2016.pdf, question 2.1.

On veut réaliser un vérificateur de type pour un langage mini C. La grammaire yacc considérée pour les déclarations est:

code

1declaration        : basic_type declarator ;2basic_type         : INT | FLOAT | VOID ;3declarator         : '*' direct_declarator | direct_declarator ;4direct_declarator  : IDENTIFIER5                   | '(' declarator ')'6                   | direct_declarator '(' parameter_list ')'7                   | direct_declarator '(' ')' ;8parameter_list     : parameter parameter_list | parameter ;9parameter          : declaration ;

Question. Proposez une structure C pour décrire les types déclarables avec cette grammaire. Représentez avec cette structure les déclarations float p(float a, int *b) et float *foo(float (*hook)(float a), int b).

Correction détaillée

Méthode: lire la grammaire comme un constructeur de types. Chaque non-terminal correspond à une opération de construction de type:

  • basic_type produit un type de base (INT, FLOAT, VOID);
  • declarator : '*' direct_declarator produit un pointeur vers le type engendré par direct_declarator;
  • direct_declarator : IDENTIFIER produit un type « encore inconnu », porteur d'un nom;
  • direct_declarator : direct_declarator '(' parameter_list ')' produit une fonction dont le type de retour est celui du direct_declarator de gauche et dont les paramètres sont ceux de droite;
  • direct_declarator : '(' declarator ')' ne construit rien: c'est un groupement, il ne sert qu'à fixer la lecture.

Un type est donc soit un atome (type de base), soit un constructeur à un argument (pointeur), soit un constructeur à deux arguments (fonction: retour + liste de paramètres). D'où une structure récursive:

cc

1typedef enum { BASE_INT, BASE_FLOAT, BASE_VOID } base_t;2typedef enum { GENRE_BASE, GENRE_PTR, GENRE_FUNC } genre_t;3 4typedef struct type_s type_t;5 6typedef struct param_s {7  char        *nom;      /* le nom du paramètre, pour les messages d'erreur */8  type_t      *type;     /* son type */9  struct param_s *suiv;  /* liste chaînée: l'ordre des paramètres compte */10} param_t;11 12struct type_s {13  genre_t  genre;        /* BASE, PTR ou FUNC */14  base_t   base;         /* si genre == GENRE_BASE */

Pourquoi cette forme et pas une autre. Un type est un arbre, pas une chaîne de caractères: int * et int ** sont deux types différents, int (*f)(void) et int *f(void) aussi. Le champ genre est le discriminant (l'équivalent d'un tag de variante); cible porte l'information structurelle; params porte la liste ordonnée des paramètres. Les types de base sont au fond de la récursion, ce qui explique la règle declaration : basic_type declarator de la grammaire: la base n'est connue qu'à la toute fin de l'analyse, on la « bouche » alors dans le trou laissé par la feuille IDENTIFIER.

Déclaration float p(float a, int *b). On lit: p est une fonction retournant float, prenant un float nommé a et un pointeur vers int nommé b.

code

1FUNC  cible = BASE(FLOAT)2      params ─┬─ { nom = "a", type = BASE(FLOAT) }3              └─ { nom = "b", type = PTR( cible = BASE(INT) ) } ─── suivant = NULL

Sous forme d'initialisation C:

cc

1type_t *t_a  = type_base(BASE_FLOAT);2type_t *t_b  = type_ptr(type_base(BASE_INT));3param_t *pa  = cons_param("a", t_a, NULL);4param_t *pb  = cons_param("b", t_b, pa);5type_t  *t_p = type_fonction(type_base(BASE_FLOAT), pb);

Déclaration float *foo(float (*hook)(float a), int b). C'est le cas qui piège: foo est une fonction qui retourne un pointeur sur float, et dont le premier paramètre est un pointeur sur fonction. On lit de l'intérieur vers l'extérieur, en suivant la règle de la « spirale »:

code

1FUNC  cible = PTR( cible = BASE(FLOAT) )      ← foo retourne float *2      params ─┬─ { nom = "hook",3              │   type = PTR( cible = FUNC  cible = BASE(FLOAT)4              │                            params = { nom = "a", type = BASE(FLOAT) } ) }5              └─ { nom = "b", type = BASE(INT) }

Autrement dit: hook n'est pas une fonction, c'est un pointeur vers une fonction. Le GENRE_FUNC n'est jamais atteint directement depuis un paramètre; il est toujours derrière un GENRE_PTR. Cette distinction est précisément ce que vérifie la question 2.5 (voir exercice 5).

Erreur classique. Représenter le type par une chaîne ("float*") et compter les *. Cela marche sur les exemples simples et échoue dès qu'un pointeur de fonction apparaît: float (*hook)(float) et float *hook(float) s'écrivent presque pareil et désignent deux choses différentes. Le type doit être un arbre.

#Exercice 3 — Attributs yacc et prototypes de la table des symboles

Source: Annale_2015-2016.pdf, questions 2.2 et 2.3 (2 pts au total).

Question 2.2. Donnez les types des attributs à déclarer dans le %union pour construire les structures de type.

Question 2.3. Proposez les prototypes des fonctions permettant de stocker le type d'un identificateur dans une table des symboles, de tester si un identificateur y figure déjà, et de lire le type d'un identificateur déjà présent. Le type de la table est supposé défini et s'appelle table_t.

Correction détaillée

2.2 — le %union. Les attributs transportés par la grammaire sont exactement les valeurs produites par les règles:

  • basic_type transporte un type de base (base_t), pas un type_t *: à ce stade on ne sait encore rien de la structure;
  • declaration, declarator, direct_declarator transportent un arbre de type (type_t *);
  • parameter_list et parameter transportent une liste de paramètres (param_t *);
  • IDENTIFIER transporte une chaîne (le nom), et INT/FLOAT/VOID sont des mots-clés sans valeur, donc sans attribut.
cc

1%union {2  base_t   base;     /* basic_type */3  type_t  *type;     /* declaration, declarator, direct_declarator */4  param_t *params;   /* parameter_list, parameter */5  char    *nom;      /* IDENTIFIER */6}7 8%token <nom>  IDENTIFIER9%token <base> INT FLOAT VOID10 11%type  <base>   basic_type12%type  <type>   declaration declarator direct_declarator13%type  <params> parameter_list parameter

Deux remarques de rédaction. D'une part, basic_type n'est pas de type type_t *: mélanger les deux obligerait à allouer un nœud pour chaque mot-clé et rendrait la règle declaration inutilement coûteuse. D'autre part, les terminaux INT, FLOAT, VOID portent bien un attribut <base>: c'est l'action de basic_type qui le lit, comme le montre l'exercice suivant.

2.3 — l'interface de la table des symboles. Trois opérations suffisent, et il faut les typer correctement: la table est hétérogène du point de vue de l'appelant (elle rend un type ou un booléen), d'où trois signatures distinctes plutôt qu'une fonction polymorphe.

cc

1/* Insère (nom, type) si absent. Renvoie 1 si l'insertion a eu lieu,2   0 si l'identificateur était déjà déclaré. */3int      table_stocker (table_t *t, const char *nom, type_t *type);4 5/* Teste la présence d'un identificateur. */6int      table_contient(table_t *t, const char *nom);7 8/* Lit le type associé à un identificateur déjà présent.9   Précondition: table_contient(t, nom) est vrai. */10type_t  *table_lire    (table_t *t, const char *nom);

Le point à justifier: table_stocker renvoie un entier, non void. La question 2.5 demande justement de détecter les redéclarations, et c'est ce code de retour qui le permet sans parcourir la table une seconde fois.

#Exercice 4 — Actions sémantiques yacc

Source: Annale_2015-2016.pdf, question 2.4 (3 pts); Annale_Session2_2015-2016.pdf, question 2.4.

Question. Écrivez les actions sémantiques yacc pour chaque règle syntaxique, permettant de remplir la table des symboles.

Correction détaillée

Le point délicat: le type de base arrive après le déclarateur. La règle est declaration : basic_type declarator. En analyse ascendante (yacc), declarator est réduit avant basic_type: au moment où l'on construit le nœud de declarator, la base est encore inconnue. La solution standard est de laisser un trou dans la feuille IDENTIFIER, puis de le boucher au moment de la réduction de declaration. On ajoute donc un champ type_t *base_a_boucher (ou, plus simplement, on mémorise le nom de l'identificateur dans une variable globale pendant la réduction de direct_declarator : IDENTIFIER, ce qui suffit ici puisque la grammaire n'a qu'une déclaration en cours à la fois).

cc

1/* Règle 1 : la base comble le trou laissé par la feuille IDENTIFIER. */2declaration3    : basic_type declarator4        { $$ = $2; type_boucher_base($2, $1); }5    ;6 7basic_type8    : INT   { $$ = BASE_INT;   }9    | FLOAT { $$ = BASE_FLOAT; }10    | VOID  { $$ = BASE_VOID;  }11    ;12 13/* '*' devant un déclarateur = un pointeur vers ce déclarateur. */14declarator

Justifications à écrire sur la copie.

  1. declarator : '*' direct_declarator enveloppe le type de droite dans un GENRE_PTR. L'ordre est contre-intuitif: dans float *foo(...), le * est à gauche de foo mais c'est le type de retour de foo qui devient un pointeur, pas foo lui-même. C'est type_fonction($1, $3) qui construit FUNC(cible = $1), et $1 vaut alors PTR(FLOAT): la lecture est correcte.
  2. direct_declarator : '(' declarator ')' ne fait rien d'autre que propager $2. Les parenthèses ne créent pas de type: elles forcent seulement la dérivation, exactement comme dans les expressions arithmétiques où (a+b)*c et a+b*c ont des arbres différents mais les mêmes nœuds.
  3. parameter : declaration réutilise la règle complète, ce qui autorise récursivement les pointeurs de fonction en paramètre — c'est ce qui rend float (*hook)(float a) analysable.
  4. La table des symboles n'est pas remplie ici: elle l'est dans declarator_list (exercice suivant), car c'est seulement là qu'on connaît le nom complet et qu'on peut détecter les collisions.

#Exercice 5 — Vérifications d'erreurs: redéclaration et fonctions imbriquées

Source: Annale_2015-2016.pdf, question 2.5 (1,5 pt); Annale_Session2_2015-2016.pdf, question 2.5.

On ajoute la règle declarator_list : declarator_list declarator | declarator.

Question. Écrivez les actions sémantiques de cette règle qui effectuent les vérifications suivantes: un identificateur ne peut pas être déclaré deux fois; les fonctions ne peuvent pas retourner une fonction ni prendre une fonction en argument.

Correction détaillée
cc

1declarator_list2    : declarator_list declarator { enregistrer($2); }3    | declarator                 { enregistrer($1); }4    ;

avec

cc

1static void enregistrer(type_t *t)2{3  char *nom = declarateur_nom(t);4 5  /* (1) redéclaration ? */6  if (table_contient(&table, nom)) {7    erreur("identificateur %s deja declare", nom);8    return;                       /* on n'ecrase pas l'ancien type */9  }10 11  /* (2) fonction retournant une fonction, ou fonction en parametre ? */12  if (contient_fonction_imbriquee(t)) {13    erreur("%s : une fonction ne peut ni retourner ni prendre une fonction", nom);14    return;

et le parcours qui implémente la vérification (2):

cc

1/* Renvoie 1 si le type contient une fonction « nue » la ou c'est interdit. */2static int contient_fonction_imbriquee(const type_t *t)3{4  if (!t || t->genre == GENRE_BASE) return 0;5 6  if (t->genre == GENRE_PTR)                 /* un pointeur sur fonction est licite */7    return contient_fonction_imbriquee(t->cible);8 9  /* genre == GENRE_FUNC */10  if (t->cible && t->cible->genre == GENRE_FUNC)   /* retour = fonction : interdit */11    return 1;12 13  for (param_t *p = t->params; p; p = p->suiv)14    if (p->type && p->type->genre == GENRE_FUNC)   /* parametre = fonction nue : interdit */

Pourquoi la récursion sur GENRE_PTR est indispensable. C'est le cœur de l'exercice. float *foo(float (*hook)(float a), int b) est valide: hook est un paramètre dont le type est PTR(FUNC(...)), et un pointeur sur fonction est une valeur parfaitement légitime en C. En revanche float f(float g(float)) — un paramètre de type FUNC(...) sans pointeur — est invalide: on ne peut pas passer une fonction « nue » en argument. La fonction de vérification doit donc s'arrêter (et renvoyer 0) dès qu'elle traverse un GENRE_PTR, et ne signaler l'erreur que sur un GENRE_FUNC atteint sans pointeur intermédiaire.

Formulation générale du C réel. Cette contrainte n'est pas propre à l'exercice: la norme C interdit les types fonction retournant fonction et fonction prenant une fonction en paramètre, mais autorise les pointeurs correspondants. C'est pourquoi la grammaire de déclarateurs impose le passage par declarator (qui peut produire un *) pour les paramètres de type fonction.

Erreur classique. Écrire if (t->cible->genre == GENRE_FUNC) return 1; sans distinguer GENRE_PTR de GENRE_FUNC en tête de fonction: la vérification rejette alors float (*hook)(float), qui est licite. Tester la correction sur les deux déclarations de l'exercice 2 est le moyen le plus rapide de détecter ce bug.

#Exercice 6 — Sethi-Ullman: arbre, registres, code optimal

Source: Annale_2015-2016.pdf, questions 3.1 à 3.3 (5 pts); Annale_Session2_2015-2016.pdf, questions 3.1 à 3.3; Annale_Session2_2017-2018.pdf, questions 3.1 à 3.3.

On utilise l'algorithme de Sethi-Ullman vu en cours et en TD pour générer un code optimal (trois adresses) pour l'expression arithmétique:

res=((a+b)×(c+d)ac×(b+d)+a+b×e)×((x1+b)×(cy2)x1×x2)res = \Big(\frac{(a+b)\times(c+d)}{a-c}\times(b+d) + a + b\times e\Big) \times \Big(\frac{(x_1+b)\times(c-y_2)}{x_1\times x_2}\Big)

Questions. 3.1 Dessinez l'arbre binaire abstrait. 3.2 Décorez l'arbre pour déduire le nombre minimal de registres nécessaires à un code optimal sans spill. 3.3 Donnez le code trois adresses optimal.

Correction détaillée

3.1 — l'arbre. Attention aux parenthèses et aux priorités. Les opérateurs * et / sont plus prioritaires que + et -, et tous sont associatifs à gauche. En appliquant ces règles, la structure de la partie gauche est (((a+b)*(c+d))/(a-c))*(b+d) puis + a puis + (b*e), et la racine de l'expression entière est le * extérieur:

code

1*                                                      (racine)2├── +3│   ├── +4│   │   ├── *5│   │   │   ├── /6│   │   │   │   ├── *7│   │   │   │   │   ├── + (a+b)8│   │   │   │   │   └── + (c+d)9│   │   │   │   └── - (a-c)10│   │   │   └── + (b+d)11│   │   └── a12│   └── * (b*e)13└── /14    ├── *

3.2 — les nombres de Sethi-Ullman (nombres d'Ershov). Règle de décoration: une feuille reçoit le nombre 1; pour un nœud interne dont les sous-arbres gauche et droit portent les nombres ll et rr,

nombre(n)={l+1si l=rmax(l,r)si lr\text{nombre}(n) = \begin{cases} l+1 & \text{si } l = r \\ \max(l,r) & \text{si } l \neq r \end{cases}

Le nombre obtenu à la racine est le nombre minimal de registres nécessaires pour évaluer l'expression sans code de vidage. Intuition: si les deux sous-arbres demandent le même nombre de registres, il faut en plus conserver le résultat du premier pendant l'évaluation du second; si l'un en demande strictement moins, ce résultat « tient » dans les registres déjà occupés par le plus gourmand.

Décoration, de bas en haut:

code

1* [4]2├── + [3]3│   ├── + [3]4│   │   ├── * [3]5│   │   │   ├── / [3]6│   │   │   │   ├── * [3]      ← 2,2 ⇒ 37│   │   │   │   │   ├── + [2] (a+b)8│   │   │   │   │   └── + [2] (c+d)9│   │   │   │   └── - [2] (a-c)10│   │   │   └── + [2] (b+d)11│   │   └── a [1]12│   └── * [2] (b*e)13└── / [3]14    ├── * [3]                  ← 2,2 ⇒ 3

À la racine, les deux fils portent l=r=3l = r = 3, donc le nombre est 3+1=43+1 = 4.

nombre minimal de registres=4\boxed{\text{nombre minimal de registres} = 4}

Vérification de deux nœuds au passage: (a+b)*(c+d) a deux fils de nombre 2 ⇒ 3. ((a+b)*(c+d))/(a-c) a des fils de nombres 3 et 2, donc max(3,2)=3\max(3,2) = 3: la division n'exige pas de registre supplémentaire, le sous-arbre gauche gardant la main.

3.3 — code trois adresses optimal (4 registres). La règle de génération découle de la décoration: on évalue d'abord le fils de plus grand nombre, puis l'autre, puis on combine. Si lrl \ge r, le sous-arbre droit est évalué avec un registre de moins, puisque le registre du résultat gauche est déjà occupé.

asmasm

1; ---- partie gauche : (a+b)*(c+d)/(a-c)*(b+d) + a + b*e ----2R0 = a3R1 = b4R0 = R0 + R1          ; R0 = a+b5R1 = c6R2 = d7R1 = R1 + R2          ; R1 = c+d8R0 = R0 * R1          ; R0 = (a+b)*(c+d)9R1 = a10R2 = c11R1 = R1 - R2          ; R1 = a-c12R0 = R0 / R1          ; R0 = (a+b)*(c+d)/(a-c)13R1 = b14R2 = d

33 instructions, 4 registres, aucun spill. Le nombre de registres effectivement utilisés simultanément ne dépasse jamais 4, et il atteint 4 exactement dans la partie droite (R1, R2, R3 occupés plus R0 qui conserve la partie gauche): c'est le nœud racine qui impose la borne.

Point de méthode. Le code optimal n'est pas « le plus court »: il est celui qui minimise le nombre de registres simultanément vivants, et Sethi-Ullman est prouvé optimal pour les arbres sans re-matérialisation. Deux erreurs classiques: évaluer systématiquement le fils gauche en premier (ici c'est correct à la racine, mais faux dès que l<rl < r, cas qui se produit dans la partie droite si l'on écrit la division dans l'autre sens), et oublier qu'un nœud dont les deux fils ont des nombres différents ne coûte pas de registre supplémentaire.

#Exercice 7 — Sethi-Ullman avec un nombre borné de registres

Source: Annale_2015-2016.pdf, question 3.4 (2 pts); Annale_Session2_2015-2016.pdf, question 3.4.

Question. On suppose que le nombre de registres disponibles est égal à trois. Générez un code optimal de l'expression de l'exercice 6, qui contient éventuellement du code de vidage (spill).

Correction détaillée

Le nombre d'Ershov de la racine vaut 4: avec 3 registres, au moins un spill est inévitable, et un seul suffit. La stratégie découle de l'arbre: les deux sous-arbres de la racine demandent chacun 3 registres. On évalue donc entièrement la partie gauche avec les 3 registres disponibles, on la vide en mémoire, puis on évalue la partie droite — qui tient dans 3 registres — et on recharge pour combiner.

asmasm

1; ---- partie gauche : 3 registres suffisent (Ershov = 3) ----2R0 = a3R1 = b4R0 = R0 + R15R1 = c6R2 = d7R1 = R1 + R28R0 = R0 * R19R1 = a10R2 = c11R1 = R1 - R212R0 = R0 / R113R1 = b14R2 = d

Bilan: 35 instructions (33 + 1 écriture mémoire + 1 lecture), 2 accès mémoire de spill, 3 registres. Le coût est minimal: le spill est placé à la frontière de la racine, c'est-à-dire à l'endroit exact où la pression sur les registres atteint 4.

Pourquoi ce spill est optimal. Le nombre d'Ershov de la racine vaut 4, donc toute allocation à 3 registres doit vider au moins une valeur; une seule valeur est suffisante, car les deux sous-arbres de la racine ont un nombre d'Ershov de 3 et tiennent donc chacun dans les 3 registres. Placer le spill ailleurs (par exemple à l'intérieur de la partie droite, où le nœud * a deux fils de nombre 2) n'apporterait rien et coûterait un accès mémoire supplémentaire.

Vérification. Le résultat doit être identique au code à 4 registres sur un jeu de valeurs: prendre a=1a=1, b=2b=2, c=3c=3, d=4d=4, e=5e=5, x1=6x_1=6, x2=7x_2=7, y2=8y_2=8 et exécuter les deux codes à la main. Toute divergence signale un spill mal placé (typiquement une valeur écrasée entre l'écriture et la relecture).

#Exercice 8 — Traduction dirigée par la syntaxe: chemins dans le plan

Source: Annale_Session2_2017-2018.pdf, exercice 1 (8 points).

Un point du plan, identifié par ses coordonnées cartésiennes (x,y)(x,y), part de (0,0)(0,0) et effectue des mouvements élémentaires d'une unité vers le haut (HH), le bas (BB), la gauche (GG) et la droite (DD). Un chemin est une séquence de mouvements écrite comme une expression arithmétique: B + G signifie « un mouvement vers le bas suivi d'un mouvement vers la gauche »; l'opérateur ×\times indique une répétition, 3 × G valant « trois mouvements vers la gauche ». L'opérateur ×\times est plus prioritaire que ++ (donc 3 × G + B se lit (3 × G) + B), et les parenthèses permettent de répéter un chemin complexe: 3 × (H + 7 × (B + 3 × G)).

La grammaire G0G_0 suivante, d'axiome EE, engendre ces expressions:

EE+EDN×E(E)HBGNNCCC019E \longrightarrow E + E \mid D \mid N \times E \mid (E) \mid H \mid B \mid G \qquad N \longrightarrow N\,C \mid C \qquad C \longrightarrow 0 \mid 1 \mid \dots \mid 9

Questions. 1.1 Montrez que G0G_0 est ambiguë. 1.2 Dessinez l'arbre de dérivation de 3 × (H + 5 × B). 1.3 Écrivez une grammaire G1G_1 non ambiguë telle que L(G1)=L(G0)L(G_1) = L(G_0). Puis, pour chaque question suivante, écrivez une traduction dirigée par la syntaxe sur G1G_1 (pas nécessairement en yacc), en définissant les attributs et en précisant pour chacun s'il est hérité ou synthétisé. 1.4 Combien de mouvements élémentaires comporte un chemin? 1.5 Quelles sont les coordonnées du point à l'issue du chemin? 1.6 Quelle est l'abscisse maximale de la trajectoire suivie?

Correction détaillée

1.1 — G0G_0 est ambiguë. La règle EE+EE \to E + E est doublement récursive à droite et à gauche, et elle est la seule à traiter ++: le mot H + B + G admet deux arbres de dérivation distincts, correspondant à (H+B)+G(H+B)+G et à H+(B+G)H+(B+G). Il suffit d'exhiber ces deux arbres pour conclure — c'est ce qu'on attend, pas un argument de comptage.

code

1   E                E2  /|\              /|\3 E + E            E + E4/|\   |          |   /|\5H + B G          H  B + G

Sémantiquement les deux arbres donnent le même chemin (l'addition des déplacements est associative), mais la grammaire est ambiguë au sens syntaxique: un mot a plusieurs arbres. C'est un défaut de la grammaire, pas du langage.

1.2 — arbre de dérivation de 3 × (H + 5 × B).

code

1        E2       /|\3      N × E4      |    |5      C   (E)6      |   /|\7      3  E + E8         |    \9         H    N × E10              |    |11              C    B12              |13              5

1.3 — grammaire non ambiguë G1G_1. L'ambiguïté vient de ce qu'une seule règle traite à la fois les opérateurs de priorités différentes. On la supprime en stratifiant la grammaire, exactement comme pour les expressions arithmétiques du cours:

ET+ETTN×TFFHBGD(E)E \longrightarrow T + E \mid T \qquad T \longrightarrow N \times T \mid F \qquad F \longrightarrow H \mid B \mid G \mid D \mid (E) NNCCC019N \longrightarrow N\,C \mid C \qquad C \longrightarrow 0 \mid 1 \mid \dots \mid 9

Justification de la non-ambiguïté, en trois arguments:

  • Un EE est une suite de TT séparés par des +, et un TT ne contient jamais de + au niveau supérieur: la décomposition de H + B + G en T+ET + E est donc unique (le premier + non parenthésé). La récursion à droite fixe l'associativité à droite, sans créer de choix.
  • Un TT est soit un facteur, soit N × TNN est une suite de chiffres. Comme NN est maximal (la règle NNCN \to NC force à absorber tous les chiffres), le découpage N × ... est unique.
  • FF est atomique: aucun choix interne.

On vérifie sur les deux cas qui départagent G0G_0 et G1G_1: 3 × G + B se lit (3 × G) + B (la partie TT s'arrête avant le +), et 3 × (H + 5 × B) exige les parenthèses pour répéter une somme — ce qui est bien la sémantique annoncée par l'énoncé.

1.4 — nombre de mouvements élémentaires. Attribut n, synthétisé sur EE, TT, FF, plus un attribut v (valeur entière du nombre) synthétisé sur NN et CC.

ProductionAction sémantique
ET+E1E \to T + E_1E.n = T.n + E1.n
ETE \to TE.n = T.n
TN×T1T \to N \times T_1T.n = N.v * T1.n
TFT \to FT.n = F.n
FHBGDF \to H \mid B \mid G \mid DF.n = 1
F(E)F \to (E)F.n = E.n
NN1CN \to N_1 CN.v = N1.v * 10 + C.v
NCN \to CN.v = C.v
CC \to chiffre ddC.v = d

Tous les attributs sont synthétisés: aucune information ne descend de la racine vers les feuilles. Sur l'exemple de l'énoncé, 3 × (4 × G + D) donne 3×(4×1+1)=153 \times (4 \times 1 + 1) = 15.

1.5 — coordonnées finales. Deux attributs dx et dy, synthétisés sur EE, TT, FF.

ProductionAction sémantique
ET+E1E \to T + E_1E.dx = T.dx + E1.dx ; E.dy = T.dy + E1.dy
ETE \to TE.dx = T.dx ; E.dy = T.dy
TN×T1T \to N \times T_1T.dx = N.v * T1.dx ; T.dy = N.v * T1.dy
TFT \to FT.dx = F.dx ; T.dy = F.dy
FHF \to HF.dx = 0 ; F.dy = 1
FBF \to BF.dx = 0 ; F.dy = -1
FGF \to GF.dx = -1 ; F.dy = 0
FDF \to DF.dx = 1 ; F.dy = 0
F(E)F \to (E)F.dx = E.dx ; F.dy = E.dy

Sur G + H + D + B: (1,0)+(0,1)+(1,0)+(0,1)=(0,0)(-1,0) + (0,1) + (1,0) + (0,-1) = (0,0), conforme à l'énoncé. La répétition kk fois se traite par une simple multiplication, ce qui est bien la sémantique de N × E.

1.6 — abscisse maximale. C'est la question qui distingue les étudiants: le maximum dépend de où l'on commence, donc il semble exiger un attribut hérité (l'abscisse courante). Une solution plus élégante n'utilise que des attributs synthétisés, en transportant le déplacement:

  • dx: déplacement horizontal total du sous-chemin (synthétisé);
  • mx: abscisse maximale relativement au point de départ du sous-chemin (synthétisé).
ProductionAction sémantique
ET+E1E \to T + E_1E.dx = T.dx + E1.dx ; E.mx = max(T.mx, T.dx + E1.mx)
ETE \to TE.dx = T.dx ; E.mx = T.mx
TN×T1T \to N \times T_1T.dx = N.v * T1.dx ; T.mx = T1.mx + max(0, (N.v - 1) * T1.dx)
TFT \to FT.dx = F.dx ; T.mx = F.mx
FHF \to HF.dx = 0 ; F.mx = 0
FBF \to BF.dx = 0 ; F.mx = 0
FGF \to GF.dx = -1 ; F.mx = 0
FDF \to DF.dx = 1 ; F.mx = 1
F(E)F \to (E)F.dx = E.dx ; F.mx = E.mx

Trois points à justifier:

  1. Concaténation. La trajectoire de T+E1T + E_1 parcourt d'abord TT (maximum relatif T.mx) puis E1E_1 décalé de T.dx (maximum relatif T.dx + E1.mx). Le maximum est donc max(T.mx, T.dx + E1.mx).
  2. Répétition. kk copies de la même unité démarrent aux abscisses 0,dx,2dx,,(k1)dx0, dx, 2dx, \dots, (k-1)dx. Chaque copie atteint au plus mxmx relativement à son départ, donc le maximum global est mx+max(0,(k1)dx)mx + \max(0, (k-1)dx): si dx>0dx > 0 la dernière copie est la plus à droite, sinon c'est la première.
  3. Cas de base. Pour G, la trajectoire visite 00 puis 1-1: l'abscisse maximale est 00, pas 1-1. Pour D, elle vaut 11. C'est le point où l'on oublie facilement de compter le point de départ.

Sur l'exemple D + H + G + B de l'énoncé: DD donne (dx=1, mx=1); la queue H+G+BH+G+B donne dx = 0, mx = 0; d'où mx = max(1, 1+0) = 1, conforme à l'énoncé. Aucun attribut hérité n'est nécessaire — c'est la raison pour laquelle on transporte le déplacement.

#Exercice 9 — Expressions postfixées: grammaire et traduction

Source: Annale_Session2_2017-2018.pdf, exercice 3 (5 points).

Une expression est en notation infixée si les opérateurs sont entre leurs opérandes; elle est en notation postfixée si les opérateurs suivent leurs opérandes: 3 + 9 devient 3 9 +, et (45 + 37) × 22 devient 45 37 + 22 ×. La frontière entre opérandes numériques est marquée par le caractère : l'expression précédente s'écrit 22□45□37 + ×.

Questions. 3.1 Écrivez en notation postfixée (3 + 5) × (6 + 7) et 3 + (5 × 6) + 7. 3.2 Écrivez une grammaire GG non ambiguë engendrant les expressions postfixées à opérandes entiers, sur l'alphabet terminal {0,,9,+,×,,/,}\{0,\dots,9,+,\times,-,/,\square\}. 3.3 En associant à chaque symbole un attribut s (chaîne de caractères) donnant la représentation infixée, écrivez les actions sémantiques de GG.

Correction détaillée

3.1 — traduction. Un parcours post-ordre de l'arbre: opérandes d'abord, opérateur ensuite.

  • (3 + 5) × (6 + 7) devient 3□5 + 6□7 + ×.
  • 3 + (5 × 6) + 7: l'associativité à gauche donne (3 + (5 × 6)) + 7, donc 3□5□6□× + 7□ +.

3.2 — grammaire non ambiguë. Le point clé: une expression postfixée valide est un mot où tout préfixe strict contient plus d'opérandes que d'opérateurs, et le mot entier contient exactement un opérande de plus que d'opérateurs. Cette propriété se traduit directement en grammaire, en distinguant « un nombre » de « une expression complète »:

ENEEOO+×/E \longrightarrow N \mid E\,E\,O \qquad O \longrightarrow + \mid \times \mid - \mid / NCCNC019N \longrightarrow C\,\square \mid C\,N \qquad C \longrightarrow 0 \mid 1 \mid \dots \mid 9

Justification de la non-ambiguïté — c'est le cœur de la question:

  • NN est non ambigu: N → C N | C □ place le en dernière position, et la lecture d'un nombre est donc déterminée par la position de son marqueur de fin.
  • EEEOE \to E\,E\,O est non ambigu: dans un mot postfixé valide, le dernier symbole est nécessairement l'opérateur racine, et le découpage E E est alors unique. En effet le premier symbole est un nombre; soit pp la longueur du plus court préfixe qui soit une expression complète (il existe, et il est unique puisqu'un préfixe valide se termine par un opérateur ou un ). Alors E1E_1 est exactement ce préfixe, et E2E_2 est le reste. Aucun autre découpage ne peut fonctionner, car tout autre choix laisserait un des deux facteurs invalide.

Autrement dit, la notation postfixée est un code uniquement décodable: la position des opérateurs détermine l'arbre sans aucune convention de priorité. C'est précisément pour cette raison que les compilateurs l'utilisent comme représentation intermédiaire avant la génération de code à pile (voir la page Génération de code).

3.3 — actions sémantiques. Attribut s, synthétisé, de type chaîne, sur EE, NN et CC. On produit une forme infixée entièrement parenthésée, ce qui garantit que la chaîne obtenue est elle-même non ambiguë.

ProductionAction sémantique
ENE \to NE.s = N.s
EE1E2OE \to E_1 E_2 OE.s = "(" + E1.s + O.s + E2.s + ")"
NCN \to C\,\squareN.s = C.s
NCN1N \to C\,N_1N.s = C.s + N1.s
CC \to chiffre ddC.s = d

Sur 22□45□37 + ×: la sous-expression 45□37 + produit "(45+37)", puis l'expression complète produit "(22(45+37)×)". Si l'on veut la forme infixée minimale (sans parenthèses superflues), il faut un attribut supplémentaire de priorité, et les actions deviennent conditionnelles — c'est le sujet classique de la « jolie impression » d'un arbre. La forme parenthésée est celle attendue ici, car elle est correcte sans information supplémentaire.

Vérification. La chaîne produite doit se reparser en une expression infixée équivalente. Sur 3□5□6□× + 7□ +, les actions donnent "((3(5×6))+7)": en évaluant par le parcours post-ordre de l'arbre d'origine, on retrouve 3+5×6+7=403 + 5 \times 6 + 7 = 40. Toute divergence indique une inversion des opérandes dans la règle EE1E2OE \to E_1 E_2 O — l'erreur la plus fréquente sur cet exercice.

#Ce que ces sujets évaluent, en résumé

ThèmeAnnale 2015-2016Session 2 2015-2016Session 2 2017-2018
Analyse lexicale (lex)5 pts5 pts
Analyse sémantique et typage8 pts8 pts
Sethi-Ullman, code trois adresses7 pts7 pts7 pts
Traduction dirigée par la syntaxe8 pts
Expressions postfixées5 pts

Trois mécanismes reviennent d'un sujet à l'autre et méritent d'être su par cœur: la règle du plus long match et l'ordre des règles dans lex; la distinction entre attributs synthétisés et hérités dans une traduction dirigée par la syntaxe; et la règle de décoration de Sethi-Ullman (l=rl+1l = r \Rightarrow l+1, sinon max(l,r)\max(l,r)) avec la génération de code qui en découle. Le reste (structures de types, tables de symboles, actions yacc) se déduit de la grammaire, à condition de savoir lire une grammaire comme un constructeur de structures de données.