Table des symbolesDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Compilation · C4 Analyse sémantique · Chapitre 1 · 4 h

Table des symboles

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 :

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 :

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 · 1 question

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

  • On cherche dans la portée globale uniquement ; cela interdit les variables localesglobale seule
  • On cherche du sommet (portée courante) vers la base, et on prend la première trouvée : cela réalise le masquage — la déclaration la plus interne l'emportede l'interne vers l'externe
  • On cherche dans toutes les portées et on signale une erreur s'il y en a plusieurserreur si plusieurs

Réponse : La résolution parcourt les portées du sommet vers la base et retient la PREMIÈRE occurrence — donc la déclaration la plus interne. C'est ce qui réalise le masquage : un x local est trouvé avant un x global, qui reste caché mais non effacé (il réapparaît à la sortie du bloc). Avoir plusieurs déclarations d'un même nom dans des portées DIFFÉRENTES n'est pas une erreur (c'est le masquage, légal) ; seule une redéclaration dans la MÊME portée l'est.

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 · 1 question

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

  • Parce que la portée statique interdit les blocs imbriquéspas de blocs
  • Parce qu'elle détermine la déclaration visée par la STRUCTURE DU TEXTE (les blocs englobants), connue à la compilation ; la portée dynamique dépend de la chaîne des appels, connue seulement à l'exécutionstructure du texte vs chaîne d'appels
  • Parce que la portée dynamique est plus rapide mais moins sûrevitesse

Réponse : En portée statique, la déclaration visée par un nom se lit dans la structure lexicale du programme — les blocs qui entourent l'usage —, entièrement disponible à la compilation. Le compilateur peut donc résoudre chaque identificateur et signaler les erreurs sans exécuter le programme. En portée dynamique, le nom dépend de QUI a appelé la fonction courante, information qui n'existe qu'à l'exécution : la résolution statique devient impossible. La portée statique autorise au contraire les blocs imbriqués (elle les gère par la pile de portées).

À 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 de code

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.

Point de départ

// 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)

Solution

function declarer(t, nom, type) {
  const courante = t.portees[t.portees.length - 1];
  if (Object.prototype.hasOwnProperty.call(courante, nom)) return "ERREUR redéclaration";
  courante[nom] = { type, niveau: t.portees.length - 1 };
  return "ok";
}
function resoudre(t, nom) {
  for (let i = t.portees.length - 1; i >= 0; i--) {   // de la plus interne vers la globale
    if (Object.prototype.hasOwnProperty.call(t.portees[i], nom)) return t.portees[i][nom];
  }
  return "non déclaré";
}

// ── Trace attendue ──────────────────────────────────────────────────────────
//  global declarer x : ok
//  bloc1  declarer y : ok
//  bloc1  resoudre x : {"type":"int","niveau":0}   <- le global
//  bloc2  declarer x : ok                            <- masque le global
//  bloc2  resoudre x : {"type":"int","niveau":2}   <- le x LOCAL
//  bloc1  resoudre x : {"type":"int","niveau":0}   <- le global de nouveau
//  bloc1  resoudre z : "non déclaré"
//  bloc1  redeclarer y : ERREUR redéclaration
//
// ── Ce que l'exercice enseigne ──────────────────────────────────────────────
//
// 1. La table est une PILE de portées. Entrer dans un bloc empile une portée
//    vide ; en sortir la dépile — et tout ce qu'elle contenait disparaît
//    d'un coup. C'est exactement le cycle de vie des variables locales.
//
// 2. La RÉSOLUTION va de l'intérieur vers l'extérieur : on prend la
//    déclaration la plus PROCHE. C'est ce qui réalise le MASQUAGE — le « x »
//    du bloc 2 cache le « x » global, sans l'effacer : à la sortie du bloc,
//    le global réapparaît intact.
//
// 3. C'est la PORTÉE STATIQUE (ou lexicale) : la variable désignée par un nom
//    se détermine par la STRUCTURE du texte (les blocs qui entourent l'usage),
//    connue à la COMPILATION. La portée DYNAMIQUE, plus rare, choisirait selon
//    la chaîne des APPELS à l'exécution — bien plus difficile à raisonner, et
//    abandonnée par presque tous les langages modernes.
//
// 4. Redéclarer dans la MÊME portée est une erreur ; masquer depuis une portée
//    englobante ne l'est pas. La table est le service transversal du
//    chapitre 1 : remplie ici, elle servira jusqu'à la génération de code
//    (où « niveau » et un décalage donneront l'adresse de la variable).

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 · 4 cartes

Que retient la table des symboles, et quand est-elle remplie et consultée ?
Pour chaque identificateur : son nom, son type, sa portée et son emplacement mémoire. Elle est REMPLIE au fil des déclarations (pendant l'analyse) et CONSULTÉE à chaque usage, jusqu'à la génération de code. C'est le service transversal du compilateur — la mémoire partagée entre « ce nom est déclaré ici » et « ce nom vaut cela, à cette adresse, plus loin ».
Comment une pile de portées gère-t-elle les blocs imbriqués ?
Entrer dans un bloc empile une portée vide ; déclarer insère dans la portée du sommet (courante) ; sortir dépile la portée (ses variables disparaissent d'un coup — le cycle de vie des locales) ; résoudre cherche du sommet vers la base et prend la première occurrence. Cette recherche de l'interne vers l'externe réalise le masquage.
Quelle est la différence entre redéclaration et masquage ?
Redéclarer un nom dans la MÊME portée est une ERREUR (deux « int x » dans le même bloc). Déclarer un nom qui existe déjà dans une portée ENGLOBANTE est LÉGAL : c'est le masquage — le nom local cache l'externe sans l'effacer, et l'externe réapparaît à la sortie du bloc. Un usage qui ne se résout dans aucune portée est « non déclaré », l'erreur sémantique la plus courante.
Portée statique ou dynamique : quelle différence, et pourquoi la statique domine ?
En portée STATIQUE (lexicale), le nom visé se détermine par la structure du TEXTE (blocs englobants), connue à la compilation — le compilateur peut donc résoudre et vérifier sans exécuter. En portée DYNAMIQUE, il dépend de la chaîne des APPELS à l'exécution, bien plus difficile à raisonner. La statique domine car elle rend l'analyse sémantique possible en amont, et les programmes prévisibles.