Cours 3

Pointeurs, structures,

récursivité & parseur

On lit du LISP, on l'évalue, on le compile en bytecode

Laurent Thiry — Programmation en C

« Les pointeurs, c'est comme les adresses : si vous perdez le papier,
vous ne retrouvez plus la maison. Ni le résultat de votre expression. »

1

🗺️ Plan du cours

Au cours 2 on a construit la VM. Au cours 3, on écrit le compilateur qui l'alimente. La boucle est bouclée.
2

1. Introduction — la calculette LISP

On part d'une chaîne comme "(+ 2 (* 3 4))" et on arrive à :

📝 Texte
"(+ 2 (* 3 4))"
🔤 Tokens
( + 2 ( * 3 4 ) )
🌳 AST
arbre en mémoire
⚙️ Bytecode
PUSH 2… PRINT HALT

calc.c (414 lignes) fait tout : parse, évalue, compile, REPL.

LISP, c'est « Beaucoup de Parenthèses » pour les uns, « LISt Processing » pour les autres. Les deux ont raison.
3

2. Pointeurs — l'adresse en mémoire

Un pointeur est une variable qui contient une adresse mémoire.

int x = 42;
int *p = &x;     // & = "adresse de"
printf("%d", *p);  // * = "valeur pointée" → 42
*p = 99;            // x vaut maintenant 99
OpérateurNomExemple
&xAdresse de xint *p = &x;
*pDéréférencementint v = *p;
p->xAccès membre via pointeurn->valeur (= (*n).valeur)
💡 Un pointeur NULL (int *p = NULL;) ne pointe nulle part. Déréférencer NULL → crash immédiat. Toujours vérifier !
Les pointeurs, c'est comme le GPS : si vous mettez NULL comme adresse, vous arrivez dans le décor. Segmentation fault assurée.
4

3. Structures

Regrouper des variables de types différents dans une structure :

typedef struct Node {
    int    type;      // 0 = nombre, 1 = opérateur
    int    valeur;    // pour les nombres
    char   op;        // pour les opérateurs : + - * /
    struct Node *gauche;
    struct Node *droite;
} Node;

typedef crée un alias : on écrit Node au lieu de struct Node.

Node *n = malloc(sizeof(Node));
n->type   = 0;     // -> = accès membre via pointeur
n->valeur = 42;
✏️ Exercice : Ajoutez un champ char *nom à la structure. Quand faut-il allouer la chaîne ? Quand la libérer ?
5

4. Chaînes & allocation dynamique

Chaînes C

Tableau de char terminé par '\0' :

char s[] = "hello";
// s = ['h','e','l','l','o','\0']
strlen(s);   // → 5
strcmp(a,b); // compare
strcpy(d,s); // copie

malloc / free

Allouer sur le tas (heap) :

int *p = malloc(10 * sizeof(int));
if (!p) { /* échec */ }
p[0] = 42;
free(p);     // libérer
p = NULL;   // bonne pratique
🐛 Fuite mémoire : tout malloc doit avoir son free. Sinon, la mémoire « fuit » — votre programme devient obèse.
malloc, c'est comme emprunter un livre à la bibliothèque. free, c'est le rendre. Oublier free, c'est garder le livre jusqu'à ce que le bibliothécaire vous frappe à la porte (ou que le tas déborde).
6

5. Récursivité

Une fonction qui s'appelle elle-même — avec une condition d'arrêt.

Factorielle (réécrite)

long long fact(int n) {
    if (n <= 1) return 1;
    return n * fact(n - 1);
}

Parcours d'AST

int evaluer(Node *n) {
    if (n->type == 0)
        return n->valeur;
    int vg = evaluer(n->gauche);
    int vd = evaluer(n->droite);
    // combiner vg et vd selon l'op
}
RécursivitéItération (boucle)
Plus élégante (code = spé)Plus économique (pas de pile d'appels)
Naturelle pour les arbresNaturelle pour les tableaux
Risque de stack overflowPas de limite théorique
La récursivité, c'est comme les poupées russes : chaque appel ouvre une nouvelle poupée. Mais contrairement aux poupées, il faut refermer toutes les boîtes dans l'ordre inverse. Et attention à la pile.
7

6. Tokeniseur — du texte aux jetons

Première étape : découper la chaîne en tokens (unités lexicales).

static char *pos;
static char token[64];

void token_suivant(void) {
    while (*pos && isspace(*pos)) pos++;  // skip espaces
    if (*pos == '(' || *pos == ')') {    // parenthèse
        token[0] = *pos++; token[1] = '\0';
        return;
    }
    if (strchr("+-*/", *pos)) {           // opérateur
        token[0] = *pos++; token[1] = '\0';
        return;
    }
    // nombre (avec chiffres)
    int i = 0;
    while (isdigit((unsigned char)*pos))
        token[i++] = *pos++;
    token[i] = '\0';
}
"(+ 2 (* 3 4))"
( + 2 ( * 3 4 ) )
8

7. Parseur récursif descendant

Grammaire : expr → '(' op expr expr ')' | nombre

Node *parse_expr(void) {
    if (strcmp(token, "(") == 0) {   // liste → opérateur
        token_suivant();                // consommer '('
        char op = token[0];              // lire l'opérateur
        token_suivant();                // passer à l'opérande
        Node *g = parse_expr();          // enfant gauche
        Node *d = parse_expr();          // enfant droit
        token_suivant();                // consommer ')'
        return nouvel_operateur(op, g, d);
    }
    // atome → nombre
    int v = atoi(token);
    token_suivant();
    return nouveau_nombre(v);
}

Il s'appelle lui-même pour les sous-expressions → récursif descendant.

Un parseur récursif, c'est comme un miroir qui se regarde dans un miroir. Sauf qu'à la place de reflets infinis, on obtient un arbre. C'est plus utile pour faire du calcul que pour philosopher.
9

7.2 Fonctionnement pas-à-pas

Pour (+ 2 (* 3 4)) :

TokenActionRésultat
(Début expression← opérateur
+Opérateurop = '+'
2Nombregauche = 2
(Début sous-expression← récursion
*Opérateurop = '*'
3Nombregauche = 3
4Nombredroite = 4
)Fin sous-expression(* 3 4)
)Fin expression(+ 2 (* 3 4))
Règle
Token courant
Appel récursif
💡 Si un token inattendu arrive (ex: 2 + 2), le parseur affiche une erreur et exit. C'est sévère mais efficace.
10

8. AST — l'arbre syntaxique abstrait

L'AST est la structure en mémoire de l'expression, avec des pointeurs gauche et droite.

'+'
opérateur
2
nombre
'*'
opérateur
3
nombre
4
nombre

Affichage indenté (depth-first) :

opérateur '+'
  nombre 2
  opérateur '*'
    nombre 3
    nombre 4
L'AST, c'est comme les poupées russes de la syntaxe. Chaque parenthèse ouvre une nouvelle couche. Sauf qu'ici, on peut compter les poupées.
11

9. Évaluateur — calculer l'arbre

Parcours postfixé (post-order) : on évalue d'abord les enfants, puis le parent.

int evaluer(Node *n) {
    if (n->type == 0)               // nombre
        return n->valeur;

    int vg = evaluer(n->gauche);   // ← gauche d'abord
    int vd = evaluer(n->droite);    // ← droite ensuite

    switch (n->op) {
        case '+': return vg + vd;
        case '*': return vg * vd;
    }
}
evaluer('+') evaluer(2)=2 evaluer('*') evaluer(3)=3; evaluer(4)=4; 3*4=12 2+12=14
💡 L'évaluateur supporte + - * / et la division par zéro. Le compilateur VM refuse la division (pas de DIV dans la VM). Un langage peut avoir plus de features en interprété qu'en compilé.
12

10. Compilateur — s-exp → bytecode VM

Même parcours postfixé, mais au lieu de calculer, on émet des instructions.

void compiler_expr(Bytecode *bc, Node *n) {
    if (n->type == 0) {
        emettre(bc, PUSH);
        emettre(bc, n->valeur);
        return;
    }
    compiler_expr(bc, n->gauche);
    compiler_expr(bc, n->droite);
    switch (n->op) {
        case '+': emettre(bc, ADD); break;
        case '*': emettre(bc, MUL); break;
    }
}

Bytecode produit

PUSH 2
PUSH 3
PUSH 4
MUL
ADD
PRINT
HALT

./calc -vm '(+ 2 (* 3 4))'

./calc -vm '(+ 2 (* 3 4))' -o prog.c

Compiler, c'est traduire du français au japonais : le sens reste (additionner 2 et 3×4), mais la forme change. Et au lieu d'un dictionnaire, on utilise une fonction récursive.
13

10.2 Bytecode dynamique

Le compilateur émet dans un tableau dynamique (Bytecode) :

typedef struct {
    int *code;
    int taille;
    int capacite;
} Bytecode;

void emettre(Bytecode *bc, int v) {
    if (bc->taille >= bc->capacite) {
        bc->capacite *= 2;
        bc->code = realloc(bc->code,
                          bc->capacite * sizeof(int));
    }
    bc->code[bc->taille++] = v;
}
✏️ Exercice : Modifiez la VM (vm.c) pour ajouter DIV. Puis modifiez le compilateur pour supporter la division. Combien de lignes changées ? 3 dans vm.c, 3 dans calc.c.
14

10.3 Génération de fichier .c

Le compilateur peut aussi écrire un fichier .C contenant le bytecode :

// Fichier généré par : ./calc -vm '(+ 2 (* 3 4))' -o out.c
int bytecode[] = {
    PUSH, 2,
    PUSH, 3,
    PUSH, 4,
    MUL,
    ADD,
    PRINT,
    HALT
};
int taille_bytecode = sizeof(bytecode) / sizeof(bytecode[0]);
calc.c
compilateur
out.c
bytecode pré-compilé
vm.c
exécution

On peut même #include le fichier dans vm.c. C'est de la génération de code : un programme qui écrit un autre programme.

15

11. REPL — la boucle interactive

Read → Eval → Print → Loop :

> (+ 2 (* 3 4))
  => 14
> (* (+ 2 3) (- 5 2))
  => 15
> quit
Au revoir !

Le REPL utilise fgets pour lire l'entrée, parse_expr + evaluer pour le calcul :

void repl(void) {
    char ligne[1024];
    printf("> ");
    while (fgets(ligne, sizeof(ligne), stdin)) {
        traiter_expression(ligne, 0, NULL);
        printf("> ");
    }
}
Le REPL, c'est un peu le Siri du C en mode pauvre. Mais contrairement à Siri, il fait exactement ce que vous dites. Et il ne vous espionne pas. On espère.
16

11.2 Modes d'utilisation

calc.c accepte plusieurs modes via la ligne de commande :

CommandeAction
./calcREPL interactif
./calc "(+ 1 2)"Évaluer et afficher le résultat
./calc -vm "(+ 1 2)"Compiler en bytecode VM (affiche le désassemblage)
./calc -vm "expr" -o prog.cÉcrire le bytecode dans un fichier .C
./calc -f entree.txtÉvaluer une expression depuis un fichier
./calc -vm -f e -o f.cCompilation depuis fichier vers fichier
✏️ Exercice : Lancez make calc-run pour le REPL, puis testez ./calc -vm '(* 2 (+ 3 4))' pour voir le bytecode.
17

12. LISP — l'héritage

LISP (1958) est le 2e plus vieux langage. Sa grande idée : code et données ont la même forme.

ConceptLISPNotre C
Notation(+ 2 (* 3 4))2 + 3 * 4
StructureListes chaînées (cons cells)struct Node + pointeurs
ParsingIntégré au langageParseur récursif manuel
ÉvaluationFonction evalFonction evaluer
CompilationFonction compileFonction compiler_expr
LISP, les parenthèses, c'est comme le sel : trop peu, c'est fade ; trop, c'est immangeable. L'avantage du LISP, c'est qu'on peut décider de la quantité. Mais ici, c'est nous la recette.
18

Résumé du cours

ConceptÀ retenir
PointeurVariable contenant une adresse mémoire (& pour l'obtenir, * pour la suivre)
StructureRegroupement de variables de types différents
malloc/freeAllocation/libération sur le tas — ne pas oublier free
Chaîne CTableau de char terminé par '\0'
RécursivitéFonction qui s'appelle elle-même (avec condition d'arrêt)
TokeniseurTexte → jetons (unités lexicales)
ParseurTokens → arbre (AST)
ÉvaluateurArbre → valeur (parcours postfixé)
CompilateurArbre → bytecode VM (parcours postfixé aussi)
REPLBoucle interactive Read-Eval-Print

L'évaluateur et le compilateur font le même parcours d'arbre. L'un calcule, l'autre génère du code. C'est la même recette, mais avec des ingrédients différents.

19

Du texte au bytecode en 4 étapes

Tokeniseur → Parseur → AST → Évaluateur / Compilateur

📄 Fichiers clés
calc.c (414 lignes) · sexpression.h/.c (275+25 lignes)
vm.c (326 lignes)
📚 Cours
cours1-introduction.html · cours2-vm.html
cours3-parseur.html · cours4-template.html
🛠️ Commandes
make calc && make calc-run
./calc -vm "(+ 2 3)"
Le cours 4 ? On transforme tout ça en chatbot. Parce que parler à une VM, c'est plus sympa que de lui envoyer des entiers.
« Au commencement était la chaîne. Puis vinrent les tokens. Puis l'arbre. Et enfin, le bytecode. Et on vit que c'était bon. »
20