cursus.

Cours 4 · Analyse sémantiqueLeçon 1 sur 2

Table des symboles

4 h de lecture8 sections Version PDF

À la fin de cette leçon, vous saurez

Portées et blocs imbriqués ; organisation et implémentation ; déclaration et résolution des identificateurs ; portée statique contre dynamique.

L'analyse syntaxique a produit un arbre bien formé. Mais bien formé ne veut pas dire correct : x = y + 1 respecte la grammaire même si y n'a jamais été déclarée, ou si y est hors de portée à cet endroit. Donner un sens à l'arbre est le rôle de l'analyse sémantique — et son socle est la table des symboles, ce service transversal annoncé au chapitre 1.

Ce chapitre construit la table et, surtout, le mécanisme qui fait toute sa subtilité : les portées. Savoir à quelle déclaration se rapporte un nom, dans un programme truffé de blocs imbriqués, est une question moins évidente qu'il n'y paraît — et sa réponse conditionne toute la suite.

Ce que la table retient

La table des symboles associe à chaque identificateur du programme ce que le compilateur en sait :

  • son nom ;
  • son type (chapitre 7) ;
  • sa portée — la région du programme où il est visible ;
  • son emplacement mémoire — le décalage qui donnera son adresse à l'exécution (chapitre 10).

Elle est remplie au fil des déclarations (pendant l'analyse) et consultée à chaque usage d'un identificateur, jusqu'à la génération de code. C'est la mémoire partagée du compilateur : le pont entre « ce nom est déclaré ici » et « ce nom vaut cela, à cette adresse, plus loin ».

Portées et blocs imbriqués

Un programme n'a pas un espace de noms unique. Chaque bloc — le corps d'une fonction, l'intérieur d'un if, une paire d'accolades — ouvre une portée : une région où des noms peuvent être déclarés, qui masquent éventuellement des noms de même orthographe déclarés plus à l'extérieur.

int x;              // (0) x global{    int y;          // (1) y local au bloc    x = y;          //     ici x = le global, y = le local    {        int x;      // (2) un NOUVEAU x, qui MASQUE le global        x = 1;      //     ici x = le local du bloc interne    }    x = 2;          //     de retour : x redevient le global}

Le même identificateur x désigne deux variables différentes selon l'endroit. La question centrale du chapitre est : à quelle déclaration un usage se rapporte-t-il ?

Organiser et implémenter : une pile de portées

L'implémentation qui suit naturellement la structure en blocs est une pile de portées. Chaque portée est une table locale (nom → information) ; on empile/dépile au rythme des blocs :

  • entrer dans un bloc : empiler une portée vide ;
  • déclarer un identificateur : l'insérer dans la portée du sommet (la portée courante) ;
  • sortir du bloc : dépiler la portée — et tout ce qu'elle contenait disparaît d'un coup, ce qui est exactement le cycle de vie des variables locales ;
  • résoudre un identificateur : le chercher du sommet vers la base, et prendre la première occurrence trouvée — la déclaration la plus interne.

C'est cette recherche « de l'intérieur vers l'extérieur » qui réalise le masquage : le x interne est trouvé avant le x global, sans l'effacer ; à la sortie du bloc, le global réapparaît intact. L'exercice de ce chapitre construit exactement cette pile.

D'autres organisations existent (une table unique avec un numéro de portée, des tables de hachage chaînées), avec le même comportement observable. Le choix relève de la performance ; le modèle mental, lui, reste la pile.

Quiz · vérifiez votre compréhension Sans réponse

Dans une table des symboles à pile de portées, comment résout-on un identificateur, et qu'est-ce que cela réalise ?

Déclaration et résolution des identificateurs

Deux opérations, deux règles à ne pas confondre.

Déclarer insère un nom dans la portée courante. Y insérer un nom déjà présent dans cette même portée est une erreur de redéclaration — on ne peut pas déclarer deux fois x dans le même bloc. En revanche, déclarer x alors qu'un x existe dans une portée englobante est parfaitement légal : c'est du masquage.

Résoudre cherche la déclaration active d'un nom à un point donné. Si la recherche du sommet vers la base n'aboutit pas, l'identificateur est non déclaré — l'erreur sémantique la plus courante, celle qui manquait à l'analyse syntaxique du chapitre 1.

Ces deux vérifications — pas de redéclaration, pas d'usage non déclaré — sont les premières que l'analyse sémantique effectue, avant même de parler de types.

Portée statique et portée dynamique

Reste une question de principe, qui distingue deux familles de langages.

En portée statique (ou lexicale), le nom auquel se rapporte un usage se détermine par la structure du texte — les blocs qui entourent physiquement l'usage. C'est décidable à la compilation, en lisant le programme. C'est le modèle de la pile ci-dessus, et celui de la quasi-totalité des langages modernes.

En portée dynamique, un nom se rapporte à la dernière déclaration active dans la chaîne des appels à l'exécution — ce qui dépend de qui a appelé qui, donc n'est pas connu à la compilation. Bien plus difficile à raisonner et source de bugs subtils, elle a été presque partout abandonnée (on la retrouve dans quelques langages anciens, ou pour des variables spéciales).

La conséquence pratique est nette : parce que la portée est statique, le compilateur peut, dès l'analyse, associer chaque usage à sa déclaration et détecter les erreurs — c'est ce qui rend l'analyse sémantique possible en amont de toute exécution.

Quiz · vérifiez votre compréhension Sans réponse

Pourquoi la portée statique (lexicale) permet-elle au compilateur de résoudre les identificateurs dès l'analyse, contrairement à la portée dynamique ?

À vous

L'exercice construit le service transversal du compilateur : une table des symboles à portées imbriquées. Vous implémentez la pile de portées, la déclaration (avec l'erreur de redéclaration) et la résolution de l'intérieur vers l'extérieur, puis vous observez le masquage d'une variable globale par une locale — et le retour du global à la sortie du bloc.

C'est la mémoire partagée qui servira jusqu'au bout : le chapitre 7 y lira les types, et le chapitre 10 y ajoutera les adresses.

Exercice · JavaScript · à vous de jouer

Implémentez une table des symboles à portées imbriquées : une pile de portées, la déclaration (avec erreur de redéclaration) et la résolution de l'intérieur vers l'extérieur. Observez le masquage d'une variable globale par une locale, et le retour du global à la sortie du bloc — c'est la portée statique.

En attente
// La table des symboles suit les DÉCLARATIONS et les PORTÉES. Un bloc { ... }
// ouvre une portée ; on la ferme en sortant. Chercher un identifiant, c'est
// remonter de la portée courante vers les englobantes (portée STATIQUE).
//
// On modélise la table par une PILE de portées (chacune un dictionnaire).

function nouvelleTable() {
  return { portees: [ {} ] };   // au départ : la portée globale
}
function entrerBloc(t) { t.portees.push({}); }        // ouvre une portée
function sortirBloc(t) { t.portees.pop(); }           // ferme la portée courante

// ── À VOUS : declarer et resoudre ───────────────────────────────────────────
// declarer : ajoute (nom -> info) dans la portée COURANTE (la dernière).
//   Redéclarer dans la MÊME portée est une erreur ; masquer une variable
//   d'une portée ENGLOBANTE est autorisé.
function declarer(t, nom, type) {
  const courante = t.portees[t.portees.length - 1];
  // à compléter : si 'nom' est déjà dans 'courante' -> renvoyer "ERREUR redéclaration"
  //               sinon courante[nom] = { type, niveau: t.portees.length - 1 }; renvoyer "ok"
  return "?";
}
// resoudre : cherche 'nom' de la portée COURANTE vers la GLOBALE ; renvoie
// l'info trouvée (la plus interne), ou "non déclaré".
function resoudre(t, nom) {
  // à compléter : parcourir t.portees de la fin vers le début
  return "non déclaré";
}

// ── Scénario : imite l'analyse d'un programme ───────────────────────────────
//   int x;            // global
//   { int y; x = y;   // bloc 1
//     { int x; x = 1; // bloc 2 : ce x MASQUE le global
//     }
//     x = 2;          // de retour au bloc 1 : x redevient le global
//   }
const t = nouvelleTable();
console.log("global   declarer x :", declarer(t, "x", "int"));
entrerBloc(t);
console.log("bloc1    declarer y :", declarer(t, "y", "int"));
console.log("bloc1    resoudre x :", JSON.stringify(resoudre(t, "x")));   // le global (niveau 0)
entrerBloc(t);
console.log("bloc2    declarer x :", declarer(t, "x", "int"));           // masque le global
console.log("bloc2    resoudre x :", JSON.stringify(resoudre(t, "x")));   // le x local (niveau 2)
sortirBloc(t);
console.log("bloc1    resoudre x :", JSON.stringify(resoudre(t, "x")));   // à nouveau le global
console.log("bloc1    resoudre z :", JSON.stringify(resoudre(t, "z")));   // jamais déclaré
console.log("bloc1    redeclarer y :", declarer(t, "y", "int"));         // ERREUR (même portée)

Console de sortie
Le résultat s'affiche dans la console

Ce que la suite en fait

La table des symboles répond à « ce nom existe-t-il, et où ? ». Reste la seconde moitié de l'analyse sémantique : « cet usage a-t-il un sens ? ». Additionner un entier et une chaîne, appeler une fonction avec le mauvais nombre d'arguments, affecter un flottant à un booléen — tout cela est bien formé et bien porté, mais mal typé.

Le chapitre 7 s'attaque à la vérification de types, en s'appuyant sur la table des symboles (pour connaître le type de chaque identificateur) et sur les grammaires attribuées — la manière propre de faire remonter et redescendre de l'information dans l'arbre syntaxique.

À retenir

Flashcards · 1 / 4Toucher pour retourner
Fin de la leçon

Vous avez parcouru les 8 sections.

Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.