🧠 Cours 3 — Pointeurs, structures, récursivité & parseur

On part d'une chaîne de caractères, on la découpe en tokens, on construit un arbre avec des pointeurs, on l'évalue, et on compile le tout en bytecode pour la VM du cours 2. Bienvenue dans le monde merveilleux des s-expressions.

1. Introduction — la calculette qui se déguise en LISP

Aux cours précédents, on a appris à programmer une factorielle en C (cours 1) et à construire une machine virtuelle à pile (cours 2). On a vu le C, l'assembleur x86-64, et le bytecode de notre VM.

Maintenant, on va apprendre à lire du texte, à le comprendre (le parser), et à l'exécuter — soit directement, soit en le compilant vers la VM.

Le format qu'on va lire, c'est celui des s-expressions (expressions symboliques), popularisées par le langage LISP (1958 !). Une s-expression, ça ressemble à ça :

(+ 2 (* 3 4))

Ça dit : « additionne 2 et le résultat de la multiplication de 3 par 4 ». Réponse : 14. Notre programme va lire cette chaîne, construire un arbre en mémoire, et soit calculer le résultat, soit générer le bytecode VM correspondant.

🧙 Pourquoi appeler ça « s-expression » plutôt que « calcul » ? Parce que les informaticiens aiment les termes qui ont l'air mystérieux. « S » comme « symbolique ». Et aussi parce que « truc-avec-des-parenthèses-qui-sent-bon-les-années-60 » c'était trop long à taper.
🧪 Variation : si on écrit (+ 1 2 3), notre parseur va refuser — il attend exactement deux opérandes. Pourquoi ? Parce qu'on a fait le choix simple. Un vrai LISP accepterait une liste d'arguments. Variation laissée en exercice !

2. Pointeurs — l'adresse en mémoire

Jusqu'ici, on manipulait des variables : des boîtes nommées qui contiennent des valeurs. Mais en C, chaque variable a aussi une adresse — l'endroit où elle se trouve en mémoire.

Le pointeur est une variable qui contient une adresse. C'est un peu comme un signet dans un livre : au lieu de recopier la page, on note juste son numéro.

&variable — donne l'adresse de la variable.
type *nom — déclare un pointeur vers une valeur de type type.
*pointeur — accède à la valeur pointée (déréférencement).
📄 Premiers pas avec les pointeurs
int x = 42;
int *p = &x;   // p contient l'adresse de x

printf("x = %d\n", x);      // 42
printf("&x = %p\n", &x);   // adresse (hexadécimal)
printf("p = %p\n", p);        // la même adresse
printf("*p = %d\n", *p);     // 42 (valeur pointée)

*p = 99;                    // modifier x via p
printf("x = %d\n", x);        // 99 !

Arithmétique des pointeurs

Quand on a un pointeur char *p sur un caractère, p + 1 avance à l'octet suivant. C'est comme ça qu'on parcourt une chaîne : en faisant avancer un pointeur caractère par caractère.

Dans notre tokeniseur, on utilise exactement ça :

static char *pos;               // pointeur courant dans la chaîne

void token_suivant(void) {
    while (*pos && isspace((unsigned char)*pos))
        pos++;        // avancer tant que c'est un espace

    if (*pos == '(' || *pos == ')') {
        token[0] = *pos;
        pos++;          // avancer d'un caractère
    }
}

isspace teste si un caractère est un espace, tabulation, etc. On l'utilise avec (unsigned char) pour éviter les surprises avec les caractères négatifs (toutes les têtes sont mises en boîte).

📍 Les pointeurs, c'est comme les adresses postales : si vous perdez le papier, vous ne retrouvez plus la maison. Et si vous écrivez à la mauvaise adresse, vous déréglez la vie de quelqu'un — c'est ce qu'on appelle un segfault.
🧪 Variation : que se passe-t-il si on écrit int *p = &x; p = p + 1; ? p n'avance pas d'un octet mais de sizeof(int) octets (souvent 4). L'arithmétique des pointeurs est typée — le C sait combien d'octets fait chaque type.

3. Structures — le meuble à tiroirs

Un int contient un entier. Un tableau contient plusieurs entiers du même type. Mais si on veut regrouper des données de types différents ?

Par exemple, notre arbre (AST) a besoin de stocker : le type du nœud (nombre ou opérateur), sa valeur si c'est un nombre, l'opérateur si c'en est un, et les deux sous-arbres (gauche et droite).

struct Nom { … }; — déclare une structure (regroupement hétérogène).
typedef … NouveauNom; — crée un alias de type.
variable.membre — accès direct à un membre.
pointeur->membre — accès via un pointeur.
📄 Déclaration de notre structure Node
typedef struct Node {
    int type;              /* 0 = nombre, 1 = opérateur */
    int valeur;            /* pour les nombres */
    char op;               /* pour les opérateurs : + - * / */
    struct Node *gauche;   /* enfant gauche (pointeur !) */
    struct Node *droite;   /* enfant droit  (pointeur !) */
} Node;

La structure est auto-référente : elle contient des pointeurs vers d'autres Node. C'est comme ça qu'on construit des arbres : chaque nœud pointe vers ses enfants. Sans pointeurs, on ne pourrait pas — la récursion serait impossible.

📄 Création et manipulation d'un nœud
typedef struct Point {
    int x;
    int y;
} Point;

int main() {
    Point p;               // sur la pile
    p.x = 3;                // accès direct avec .
    p.y = 5;

    Point *pp = &p;      // pointeur vers p
    pp->x = 7;               // accès via pointeur avec ->
    // (*pp).x = 7 aurait marché aussi, mais -> c'est plus beau

    return 0;
}
🏗️ struct et typedef, c'est le Lego du C. Vous assemblez des briques de types existants pour créer des blocs plus gros. Et comme avec le vrai Lego, vous pouvez marcher dessus (segfault) si vous oubliez de vérifier vos pointeurs.
🧪 Variation : dans notre code, on utilise struct Node *gauche sans avoir fini de définir la structure — c'est légal car un pointeur n'a besoin que de la déclaration, pas de la définition complète. C'est le privilège des pointeurs !

4. Chaînes de caractères — du texte, enfin !

Au cours 1, on a vu "bonjour" comme argument de printf. Mais en C, une chaîne n'est rien d'autre qu'un tableau de char terminé par un octet nul ('\0').

char *s — pointeur vers une chaîne (ou un seul caractère).
char t[10] — tableau de 10 caractères.
fgets(buf, taille, stdin) — lit une ligne depuis l'entrée.
strlen(s) — longueur de la chaîne (hors '\0').
strcmp(a, b) — compare deux chaînes (0 = égales).
strchr(s, c) — cherche le caractère c dans s.
atoi(s) — convertit la chaîne en entier.
📄 Lire et découper une ligne
char ligne[1024];

printf("> ");
fflush(stdout);                      // vider le tampon d'affichage

if (fgets(ligne, sizeof(ligne), stdin)) {
    // enlever le '\n' final
    size_t len = strlen(ligne);
    if (len > 0 && ligne[len - 1] == '\n')
        ligne[len - 1] = '\0';

    if (strcmp(ligne, "quit") == 0)
        break;

    // chercher un opérateur
    if (strchr("+-*/", ligne[0]))
        printf("Un opérateur !\n");

    // convertir un nombre
    int v = atoi(ligne);
}

Dans notre tokeniseur, on utilise strchr("+-*/", *pos) pour tester si le caractère courant est un opérateur. Et atoi(token) pour convertir un token numérique en entier. Simple, efficace, éprouvé.

size_t est le type retourné par strlen et sizeof — c'est un entier non-signé capable de représenter n'importe quelle taille de mémoire. Il est défini dans <stddef.h> (souvent inclus indirectement).

📝 fgets avec stdin, c'est la « lecture au clavier » en C. Pas de scanner, pas de input() tout cuit — on lit des caractères et on se démerde avec. C'est spartiate, mais ça forme le caractère. Et l'estomac.
🧪 Variation : pourquoi char ligne[1024] et pas dynamique ? 1024 caractères, c'est assez pour une expression raisonnable. Si l'utilisateur tape plus, fgets lit 1023 caractères et laisse le reste dans le tampon — un comportement à gérer dans un vrai programme. Dans notre calculette, on considère que personne n'écrit des expressions de 2 km. (Pari risqué !)

5. Allocation dynamique — la vie sur le tas

Jusqu'ici, nos variables étaient statiques ou sur la pile (stack). Mais on ne sait pas à l'avance combien de nœuds notre arbre va contenir — ça dépend de l'expression tapée par l'utilisateur.

L'allocation dynamique permet de demander de la mémoire au moment de l'exécution, sur le tas (heap). En C, on utilise malloc (memory allocation) et free.

malloc(taille) — alloue taille octets, retourne un pointeur.
free(ptr) — libère la mémoire allouée.
realloc(ptr, nouvelle_taille) — redimensionne un bloc.
NULL — pointeur nul (valeur d'échec de malloc).
📄 Créer un nœud avec malloc
Node *nouveau_nombre(int v) {
    Node *n = malloc(sizeof(Node));  // assez grand pour un Node
    n->type   = 0;
    n->valeur = v;
    n->gauche = NULL;
    n->droite = NULL;
    return n;
}

Node *nouvel_operateur(char op, Node *g, Node *d) {
    Node *n = malloc(sizeof(Node));
    n->type   = 1;
    n->op     = op;
    n->gauche = g;
    n->droite = d;
    return n;
}

void liberer_node(Node *n) {
    if (!n) return;
    liberer_node(n->gauche);   // libérer récursivement
    liberer_node(n->droite);
    free(n);
}

Le tableau dynamique Bytecode utilise realloc pour grandir au fur et à mesure qu'on émet des instructions :

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;
}
⚠️ Attention : malloc peut échouer (retourner NULL) si la mémoire est pleine. Dans un programme pédagogique, on ignore cette vérification pour rester lisible. Dans la vraie vie, on vérifie ! (Et on pleure si ça arrive.)
🏕️ La mémoire, c'est comme un camping : il y a la tente (pile) où on pose ses affaires rapidement et qui se range automatiquement en partant, et le mobile-home (tas) qu'on doit louer et rendre soi-même. malloc = réservation, free = libération. Oublier free = squatter le camping. C'est une fuite mémoire (memory leak).
🧪 Variation : dans liberer_node, on vérifie if (!n) return; avant d'appeler free(n). Pourquoi ? Parce que free(NULL) est légal (il ne fait rien), mais déréférencer NULL (comme n->gauche) provoque un crash. Le test est là pour protéger l'accès aux membres.

6. Arguments en ligne de commande — parler au programme

Jusqu'ici, nos programmes ne prenaient pas d'arguments. On veut maintenant pouvoir lancer :

./calc "(+ 2 (* 3 4))"
./calc -vm "(+ 2 (* 3 4))"
./calc -vm "(+ 2 (* 3 4))" -o sortie.c
./calc -f expression.txt

C'est le rôle de main(int argc, char *argv[]).

argc — « argument count » : nombre d'arguments.
argv — « argument vector » : tableau de chaînes.
argv[0] est toujours le nom du programme.
📄 Analyser les arguments
int main(int argc, char *argv[]) {
    int mode_vm = 0;
    const char *expr = NULL;

    for (int i = 1; i < argc; i++) {
        if (strcmp(argv[i], "-vm") == 0)
            mode_vm = 1;
        else if (strcmp(argv[i], "-o") == 0 && i + 1 < argc)
            fichier_sortie = argv[++i];
        else
            expr = argv[i];
    }
}
🧅 argc et argv, c'est l'oignon du C : le programme, c'est la couche 0, les options sont les couches suivantes, et on pleure un peu à chaque fois qu'on oublie de vérifier argc avant d'accéder à argv.
🧪 Variation : on aurait pu utiliser getopt (bibliothèque standard POSIX) pour analyser les arguments. C'est plus robuste, mais moins pédagogique. Notre boucle fait comprendre le principe sans cache-misère.

7. Fichiers — sauver, charger, partager

Avec les fichiers, on peut lire une expression depuis un fichier texte (option -f) et écrire le bytecode généré dans un fichier C (-o).

FILE *f = fopen(chemin, mode) — ouvre un fichier.
fgets(buf, taille, f) — lit une ligne depuis un fichier.
fprintf(f, format, …) — écrit formaté dans un fichier.
fclose(f) — ferme le fichier.
Modes : "r" (lecture), "w" (écriture), "a" (ajout).
📄 Lire une expression depuis un fichier
FILE *f = fopen(fichier_entree, "r");
if (!f) {
    fprintf(stderr, "ERREUR: impossible de lire '%s'\n", fichier_entree);
    return 1;
}
static char buf[4096];
buf[0] = '\0';
char ligne[512];
while (fgets(ligne, sizeof(ligne), f))
    strncat(buf, ligne, sizeof(buf) - strlen(buf) - 1);
fclose(f);

stderr (vu au cours 2) est le flux d'erreur standard. On l'utilise pour les messages d'erreur — comme ça, même si l'utilisateur redirige la sortie standard (stdout) vers un fichier, les erreurs restent visibles dans le terminal.

Quand on génère un fichier .c avec -o, on écrit un tableau C complet, prêt à être copié dans vm.c ou inclus avec #include. C'est un exemple de génération de code — un programme qui en écrit un autre.

📁 La philosophie Unix : tout est fichier. Le clavier, l'écran, les disques, même les prises réseau. fopen("/dev/tty", "w"), c'est la classe. (Et /dev/null, c'est le trou noir de l'informatique.)
🧪 Variation : le fichier généré par -o utilise les noms d'opcodes VM (PUSH, ADD, etc.) qui doivent correspondre à l'enum de vm.c. Si on modifie l'enum dans un fichier, il faut le faire dans l'autre aussi. C'est une dépendance — d'où l'intérêt d'avoir un fichier d'en-tête commun (on verra ça au cours 4).

8. Récursivité — la fonction qui s'appelle elle-même

Une fonction récursive est une fonction qui s'appelle elle-même. C'est une technique élégante pour résoudre des problèmes qui ont une structure naturellement hiérarchique — comme analyser une expression mathématique ou parcourir un arbre.

Récursivité : une fonction qui se rappelle elle-même.
Il faut toujours un cas de base (qui stoppe la récursion)
et un pas de récursion (qui se rapproche du cas de base).

Exemple classique : la factorielle, qu'on a déjà écrite de façon itérative au cours 1. Voici la version récursive :

📄 Factorielle récursive
long long factorielle(int n) {
    if (n < 0)  return -1;        // cas d'erreur
    if (n <= 1) return 1;          // cas de base
    return n * factorielle(n - 1);  // pas récursif
}

Chaque appel à factorielle(n - 1) empile un nouveau cadre sur la pile d'appels. Quand on atteint le cas de base (n <= 1), les appels se déplient et les multiplications s'enchaînent.

Dans notre parseur, la récursivité est mutuelle (en fait, directe) :

// parse_expr s'appelle elle-même pour les sous-expressions
Node *parse_expr(void) {
    if (strcmp(token, "(") == 0) {
        token_suivant();                        // '('
        char op = token[0];                   // opérateur
        token_suivant();
        Node *g = parse_expr();              // ← appel récursif
        Node *d = parse_expr();              // ← appel récursif
        token_suivant();                     // ')'
        return nouvel_operateur(op, g, d);
    }
    // cas de base : c'est un nombre
    int v = atoi(token);
    token_suivant();
    return nouveau_nombre(v);
}

Le cas de base, c'est quand on rencontre un nombre (pas de parenthèse ouvrante). Sinon, on s'appelle récursivement pour lire les deux sous-expressions.

L'évaluateur aussi est récursif :

int evaluer(Node *n) {
    if (n->type == 0)                 // cas de base : nombre
        return n->valeur;
    int vg = evaluer(n->gauche);       // ← récursion
    int vd = evaluer(n->droite);       // ← récursion
    // appliquer l'opérateur
    switch (n->op) {
        case '+': return vg + vd;
        case '-': return vg - vd;
        case '*': return vg * vd;
        case '/': return vg / vd;
    }
}
🪞 La récursivité, c'est comme regarder son reflet dans un miroir qui reflète un autre miroir. À l'infini. Sauf qu'en informatique, on finit par tomber sur un cas de base (ou sur un débordement de pile — stack overflow). Si vous voyez ce message sur Stack Overflow, c'est que vous avez abusé de la récursion. Ou que vous cherchez une solution pour votre TP.
🧪 Variation : la fonction liberer_node est aussi récursive : elle libère les deux enfants, puis le nœud. C'est un parcours postfixé (ou « post-ordre »). On aurait pu l'écrire de façon itérative avec une pile explicite — comme celle de la VM du cours 2.

9. Tokeniseur — du texte aux jetons

La première étape pour comprendre une expression, c'est de la découper en tokens (ou « jetons »). Chaque token est une unité significative : une parenthèse, un opérateur, un nombre.

Exemple avec (+ 2 (* 3 4)) :

(
parenthèse
+
opérateur
2
nombre
(
parenthèse
*
opérateur
3
nombre
4
nombre
)
parenthèse
)
parenthèse

La fonction token_suivant() avance le pointeur pos dans la chaîne, saute les espaces, et remplit le buffer global token :

Les variables pos et token sont globales (statiques dans le fichier) — ça simplifie le code, car le tokeniseur et le parseur partagent un état commun. Ce n'est pas idéal pour un programme multi-threadé, mais pour notre calculette pédagogique, c'est parfait.

🎰 Le tokeniseur, c'est la machine à sous du langage : vous mettez une chaîne de caractères, et il vous recrache des jetons. Parfois il y a un joker (token inattendu), mais contrairement au casino, ici on vous prévient au lieu de vous ruiner.
🧪 Variation : si on voulait supporter des nombres à virgule (comme 3.14), il faudrait modifier le tokeniseur pour reconnaître le point. Et l'évaluateur pour gérer les float. Simple en théorie, instructif en pratique.

10. Parseur récursif descendant — la grammaire en action

Une fois qu'on a des tokens, il faut leur donner une structure. C'est le rôle du parseur. On va définir une grammaire — un ensemble de règles qui décrivent la structure d'une expression valide.

Grammaire de notre langage :
expr → '(' op expr expr ')'
expr → nombre
op → '+' | '-' | '*' | '/'

Ça se lit : « une expression, c'est soit une parenthèse ouvrante, un opérateur, deux expressions, une parenthèse fermante ; soit un nombre. » C'est une grammaire récursive — la règle expr fait référence à elle-même.

Le parseur qui implémente cette grammaire s'appelle un parseur récursif descendant (recursive descent parser). Chaque règle de la grammaire devient une fonction. Notre seule règle expr devient la fonction parse_expr() qui regarde le token courant et décide :

📄 Trace de parse_expr pour (+ 2 (* 3 4))
appel 1 : token = "(" → op = '+'
  appel 2 : token = "2" → nombre 2
  appel 3 : token = "(" → op = '*'
    appel 4 : token = "3" → nombre 3
    appel 5 : token = "4" → nombre 4
  retour 3 : nœud '*' (3, 4) après ')'
retour 1 : nœud '+' (2, *) après ')'

Chaque appel à parse_expr() consomme les tokens de l'expression et retourne un arbre. La récursivité épouse parfaitement la structure hiérarchique de la grammaire — c'est pour ça que parseur rime avec récursif.

🌳 La grammaire d'un langage, c'est comme la grammaire d'une langue naturelle : si vous écrivez « suis arbre mangé le », c'est un token superflu ou une parenthèse attendue. Le compilateur fait son Prof de français : il vous reprend sur chaque faute de syntaxe. Mais lui, il ne met pas de stylo rouge, il met exit(1).
🧪 Variation : notre grammaire n'accepte que des opérateurs binaires (deux opérandes). Un vrai LISP accepterait (+ 1 2 3 4) (somme de N nombres). Pour ça, il faudrait modifier la règle : expr → '(' op expr+ ')' (une ou plusieurs expressions). Le parcours serait alors une boucle, pas juste deux appels.

11. Arbre Syntaxique Abstrait (AST)

L'AST (Abstract Syntax Tree) est la représentation en mémoire de notre expression sous forme d'arbre. Chaque nœud est un struct Node, et les pointeurs gauche et droite relient les nœuds entre eux.

Pour (+ 2 (* 3 4)), l'AST ressemble à ça :

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

Pour afficher l'AST, on utilise une fonction récursive qui parcourt l'arbre en profondeur (depth-first) :

void afficher_ast(Node *n, int niveau) {
    for (int i = 0; i < niveau; i++) printf("  ");
    if (n->type == 0)
        printf("nombre %d\n", n->valeur);
    else {
        printf("opérateur '%c'\n", n->op);
        afficher_ast(n->gauche,  niveau + 1);
        afficher_ast(n->droite,  niveau + 1);
    }
}

Ce qui donne pour (+ 2 (* 3 4)) :

opérateur '+'
  nombre 2
  opérateur '*'
    nombre 3
    nombre 4
🌿 L'AST, c'est un arbre généalogique à l'envers : la racine est en haut (le dernier opérateur évalué), les feuilles sont en bas (les nombres). Si vous retournez vos notes de cours, vous verrez peut-être un AST. Mais pas de panique, les pointeurs ne piquent pas.
🧪 Variation : l'affichage indenté est un parcours préfixé (on affiche le nœud avant les enfants). L'évaluation utilise un parcours postfixé (on évalue les enfants avant le nœud). Faire la différence entre les trois ordres (préfixe, infixe, postfixe) est un grand classique des examens d'algorithmique.

12. Évaluateur — calculer l'arbre

Une fois qu'on a l'AST, l'évaluation est presque triviale : on parcourt l'arbre de façon récursive, on calcule la valeur de chaque sous-arbre, et on combine les résultats.

📄 Évaluateur récursif
int evaluer(Node *n) {
    if (n->type == 0)                    // c'est un nombre
        return n->valeur;

    // c'est un opérateur → évaluer les deux fils
    int vg = evaluer(n->gauche);
    int vd = evaluer(n->droite);

    switch (n->op) {
        case '+': return vg + vd;
        case '-': return vg - vd;
        case '*': return vg * vd;
        case '/':
            if (vd == 0) exit(1);
            return vg / vd;
        default:
            fprintf(stderr, "opérateur inconnu\n");
            exit(1);
    }
}

C'est un parcours en post-ordre (post-order traversal) : on évalue d'abord les enfants, puis le parent. C'est exactement ce que fait le compilateur quand il génère le bytecode (section suivante) — à ceci près qu'au lieu de retourner un entier, il émet des instructions.

La trace pour (+ 2 (* 3 4)) :

evaluer('+')
  evaluer(2) → 2
  evaluer('*')
    evaluer(3) → 3
    evaluer(4) → 43 * 4 = 122 + 12 = 14
⚠️ Division : l'évaluateur gère la division, mais le compilateur VM la refuse (la VM n'a pas d'instruction DIV). C'est un choix délibéré : montrer qu'un langage peut être interprété (évaluateur) avec des fonctionnalités que son compilateur ne supporte pas. En pratique, un langage complet a souvent plusieurs cibles de compilation.
🧪 Variation : que se passe-t-il si on tape (+ 1 (* 2 3) (parenthèse fermante manquante) ? Le parseur attend ) et tombe sur la fin des tokens — il affiche une erreur et s'arrête. La robustesse aux erreurs est un vaste sujet (on pourrait afficher la position exacte, suggérer des corrections…).

13. Compilateur — des s-expressions au bytecode VM

Le clou du spectacle : on va compiler notre s-expression vers le bytecode de la VM du cours 2. C'est un compilateur source-à-source (ou plutôt source-à-bytecode) : on traduit un langage (nos s-expressions) en un autre (les opcodes de la VM).

Le principe est simple : on parcourt l'AST en post-ordre et on émet les instructions correspondantes. Pour un nombre, on émet PUSH valeur. Pour un opérateur, on compile les deux opérandes, puis on émet l'opcode :

📄 Compilateur s-expression → bytecode VM
void compiler_expr(Bytecode *bc, Node *n) {
    if (n->type == 0) {                    // nombre → PUSH
        emettre(bc, PUSH);
        emettre(bc, n->valeur);
        return;
    }
    // opérateur : compiler d'abord les opérandes
    compiler_expr(bc, n->gauche);
    compiler_expr(bc, n->droite);
    // puis l'opération (parcours postfixé !)
    switch (n->op) {
        case '+': emettre(bc, ADD); break;
        case '-': emettre(bc, SUB); break;
        case '*': emettre(bc, MUL); break;
        case '/': /* refusé : pas de DIV dans la VM */
    }
}

Pour (+ 2 (* 3 4)), ça donne :

PUSH 2        ; évalué en dernier (additionné avec *)
PUSH 3        ; premier enfant de *
PUSH 4        ; second enfant de *
MUL           ; 3 * 4 = 12
ADD           ; 2 + 12 = 14
PRINT         ; afficher le résultat
HALT          ; arrêter la VM

Après compilation, on ajoute PRINT et HALT pour que le programme soit autonome. Le résultat peut être affiché à l'écran ou écrit dans un fichier .C avec -o.

🔄 Compiler, c'est comme traduire un livre du français au japonais : le sens reste le même (additionner 2 et le produit de 3 par 4), mais la forme change du tout au tout (des parenthèses → des opcodes numériques). Et au lieu de faire appel à un traducteur, on écrit une fonction qui s'appelle elle-même. La récursivité est partout.
🧪 Variation : notre compilateur produit un bytecode qui suppose que la pile est vide au départ et que le résultat est au sommet après PRINT. Si on voulait l'utiliser comme sous-programme appelé depuis un autre code VM (par exemple pour le factorial), il faudrait définir une convention d'appel. Un vaste sujet !

14. REPL et philosophie Unix

Le REPL

Quand on lance ./calc sans argument, on entre dans un REPLRead Eval Print Loop :

C'est exactement comme ça que fonctionnent les interpréteurs LISP, Python, Ruby, JavaScript (node), etc. — la boucle interactive est au cœur de l'expérience programmeur.

📄 Le REPL de notre calculette
void repl(void) {
    char ligne[1024];
    printf("> ");
    fflush(stdout);
    while (fgets(ligne, sizeof(ligne), stdin)) {
        /* nettoyer le '\n' */
        traiter_expression(ligne, 0, NULL);
        printf("> ");
        fflush(stdout);
    }
    printf("Au revoir !\n");
}

Philosophie Unix

Notre programme illustre plusieurs principes de la philosophie Unix :

🔧 Un outil, une tâche

Le tokeniseur tokenise. Le parseur parse. L'évaluateur évalue. Le compilateur compile. Chaque fonction fait une chose et la fait bien (ou au moins correctement).

🔗 Composition

./calc "expr" → résultat sur stdout. ./calc -f entree.txt -o sortie.c → pipeline fichier → fichier. On peut rediriger, enchaîner, combiner.

📝 Texte comme interface

L'entrée est une chaîne, la sortie est du texte ou du code C. Pas de format binaire propriétaire. On peut éditer, grep, diff, versionner.

🏗️ Modularité

On pourrait remplacer l'évaluateur par un autre sans toucher au tokeniseur. On pourrait réutiliser le compilateur dans un autre projet.

Notre programme peut être utilisé dans des pipes Unix :

# Évaluer une expression, extraire le résultat
echo "(+ 2 (* 3 4))" | ./calc | grep "=>"

# Compiler une liste d'expressions depuis un fichier
./calc -vm -f mes_expressions.txt -o tout_le_bytecode.c

Cette philosophie de « petits outils interconnectés » est ce qui a fait le succès d'Unix depuis les années 1970. Elle s'oppose à la philosophie « monolithique » d'un outil qui fait tout tout seul (comme un tableur ou un IDE).

🐧 La philosophie Unix en un slogan : « Faites une chose, et faites-la bien. » Le couteau suisse, c'est joli, mais pour couper du pain, rien ne vaut un bon couteau à pain. Et pour compter des s-expressions, rien ne vaut un parseur récursif. Même s'il tient dans 400 lignes.
🧪 Variation : la fonction fflush(stdout) est nécessaire car printf est bufférisé (les caractères sont mis en attente avant d'être envoyés au terminal). Sans fflush, le > pourrait ne pas s'afficher avant qu'on lise l'entrée. Essayez de l'enlever pour voir !

15. Types de langages — C, assembleur, LISP, VM

En trois cours, on a traversé plusieurs niveaux d'abstraction en programmation :

Langage Niveau Exécution Vu dans
Assembleur x86-64 👾 Bas niveau Direct par le CPU Cours 1 (asm-doc)
C 🏗️ Moyen niveau Compilé → assembleur Cours 1 & 2
Bytecode VM ⚙️ Machine virtuelle Interprété par vm.c Cours 2
S-expression 🧠 Haut niveau Interprété / compilé par calc.c Cours 3

Chaque niveau ajoute de l'abstraction :

Compilation vs Interprétation

Notre programme fait les deux :

En vrai LISP (ou Scheme, Clojure…), la frontière est floue. La plupart des implémentations LISP offrent un compilateur JIT (Just-In-Time) qui compile le code à chaud pendant l'exécution. Notre petite calculette montre le principe : on peut lire une expression, l'analyser, et soit l'exécuter directement, soit produire un code pour une autre machine.

LISP/Scheme

Le langage LISP (LISt Processing), créé par John McCarthy en 1958, est le deuxième plus vieux langage de programmation (après Fortran). Sa particularité : le code et les données ont la même forme — des listes entre parenthèses (des s-expressions). C'est ce qui lui donne une puissance d'abstraction redoutable (les macros).

Notre syntaxe (+ 2 (* 3 4)) vient directement de LISP. La seule différence : en LISP, on peut avoir des listes de longueur variable, et tout est expression (même les définitions de fonctions). Notre langage est un sous-ensemble minuscule de Scheme, un dialecte moderne de LISP.

🧙 LISP, ça signifie « Beaucoup de Parenthèses » (en réalité « LISt Processing », mais la blague est trop bonne). Les adeptes de LISP disent que « les parenthèses, c'est la liberté ». Les autres disent que « la liberté, c'est de pouvoir les enlever ». Les deux ont raison.
🧪 Variation : si on voulait ajouter des variables à notre langage, il faudrait une table de symboles (un dictionnaire qui associe les noms aux valeurs). Avec les structures et les pointeurs, on a tous les outils pour le faire. Un petit exercice ? (On sait où vous trouver.)

16. Bilan vocabulaire — tout ce qu'on a appris

Ce cours a introduit un bon paquet de nouveaux concepts C. En voici le résumé :

Nouveaux mots-clés

Mot-cléRôleExemple
structDéclarer une structure (type composite)struct Node { int x; };
typedefCréer un alias de typetypedef struct Node Node;
charType caractère (1 octet)char c = 'A';
char *Pointeur vers char (chaîne)char *s = "hello";
size_tType non-signé pour les taillessize_t n = strlen(s);
NULLPointeur nulif (p == NULL) …

Nouveaux opérateurs

OpérateurRôleExemple
&variableAdresse d'une variableint *p = &x;
*pointeurDéréférencement (valeur pointée)int v = *p;
->Accès membre via pointeurn->valeur
.Accès membre directp.x

Nouvelles fonctions de la bibliothèque standard

FonctionBibliothèqueRôle
fgets(buf, taille, flux)<stdio.h>Lire une ligne depuis un flux
strlen(s)<string.h>Longueur d'une chaîne
strcmp(a, b)<string.h>Comparer deux chaînes
strchr(s, c)<string.h>Chercher un caractère
atoi(s)<stdlib.h>Convertir une chaîne en entier
malloc(taille)<stdlib.h>Allouer sur le tas
free(ptr)<stdlib.h>Libérer la mémoire
realloc(ptr, t)<stdlib.h>Redimensionner un bloc
fopen(chemin, mode)<stdio.h>Ouvrir un fichier
fclose(f)<stdio.h>Fermer un fichier
fprintf(f, fmt, …)<stdio.h>Écrire formaté dans un fichier
isspace(c)<ctype.h>Tester si c'est un espace
isdigit(c)<ctype.h>Tester si c'est un chiffre

Nouveaux concepts

ConceptDescription
PointeurVariable qui contient une adresse mémoire
StructureRegroupement de variables de types différents
Allocation dynamiqueRéserver de la mémoire à l'exécution (malloc/free)
Chaîne de caractèresTableau de char terminé par '\0'
RécursivitéFonction qui s'appelle elle-même
TokenisationDécouper du texte en unités lexicales (tokens)
Analyse syntaxique (parsing)Construire un arbre à partir des tokens
ASTArbre Syntaxique Abstrait — représentation en mémoire
CompilationTraduire d'un langage à un autre
REPLRead-Eval-Print Loop — boucle interactive
argc/argvArguments passés à un programme en ligne de commande
Génération de codeProgramme qui écrit un autre programme

Et on n'a pas encore tout vu ! Il reste les unions, les pointeurs de fonctions, la gestion avancée de la mémoire, les listes chaînées, les arbres binaires de recherche… Chaque concept ouvre la porte à d'autres.