Cours 5 — S-expressions

Environnements, fermetures lexicales, cellules mutables, objets par passage de messages, listes, et transpilation LISP → Python.

🎯 Objectif : transformer un parseur d'S-expressions en un véritable langage de programmation avec variables, fonctions, mutation et objets. En C. Sans framework. Sans filet.

1. Rappel : les S-expressions et leurs limites

Depuis le cours 3, la bibliothèque sexpression.h/.c permet de parser et évaluer des S-expressions. La fonction evaluer gère les opérateurs arithmétiques, les listes, et les formes spéciales de base.

/* Ce qui fonctionne avec evaluer() de la bibliothèque */
(+ 1 2)               → 3
(car (quote (a b)))   → a
(if 1 2 3)            → 2

/* Ce qui ne fonctionne PAS : */
(let x 42)            ; pas de variable
(+ x 1)               ; x n'existe pas
(function f (n) ...)  ; pas de fonction

Problème : evaluer ne garde aucune trace des évaluations précédentes. Chaque appel repart de zéro. Pour aller plus loin, il faut un environnement : une structure qui retient les associations nom → valeur.

La fonction evaluer sans environnement, c'est comme une calculatrice qui oublie le résultat dès que vous appuyez sur « = ». Utile, mais frustrant. On va lui ajouter une mémoire.

1.1 Architecture du programme let.c

Le fichier let.c (environ 580 lignes) étend la bibliothèque Sexp avec des environnements chaînés, des closures, des cellules mutables, et des opérations sur listes. Contrairement à calc.c qui compile vers la VM, let.c est un interprète complet : il évalue les expressions directement dans une boucle REPL ou en ligne de commande.

FichierRôleLignes
sexpression.hTypes Sexp, Cell ; constructeurs ; parseur33
sexpression.cImplémentation : parse, zip, cherche, print, free, copy313
let.cÉvaluateur avec envs, closures, cellules, listes~580
transpile.cTranspileur LISP → Python~360

2. Environnements chaînés

Un environnement est une table d'association (symbole . valeur) organisée en chaîne. Chaque nouveau let empile une nouvelle portée. La recherche remonte la chaîne jusqu'à trouver le symbole.

2.1 Structure Env

typedef struct Env Env;
struct Env {
    Sexp *bindings;  /* alist : ((sym val) …) */
    Env  *parent;    /* portée englobante */
};

static Env *env_new(Env *parent) {
    Env *e = malloc(sizeof(Env));
    e->bindings = NULL;
    e->parent   = parent;
    return e;
}
/* Exemple de chaîne : */
[2] ((x 10) (y 20))
       ↑ parent
[1] ((z 100))
       ↑ parent
[0] ()    ← globale

2.2 Recherche : env_lookup

Parcourt la chaîne d'environnements en remontant les parents. Retourne la première liaison trouvée (résolution lexicale).

static Sexp *env_lookup(Env *env, Sexp *key) {
    while (env) {
        Sexp *v = cherche(key, env->bindings);
        if (v) return v;    /* trouvé dans cette portée */
        env = env->parent;  /* remonte d'un niveau */
    }
    return NULL;          /* pas trouvé → erreur */
}

La fonction cherche de la bibliothèque parcourt une liste d'association :

Sexp *cherche(Sexp *key, Sexp *alist) {
    while (alist) {
        if (key->entier == alist->car->car->entier)
            return alist->car->cdr->car;
        alist = alist->cdr;
    }
    return NULL;
}
À retenir : la résolution est lexicale (statique). On remonte la chaîne des environnements, pas la pile d'appels. C'est ce qui permet les fermetures lexicales (section 5).

3. let — lier une variable

(let x 42) crée une nouvelle portée avec la liaison x = 42 et retourne la valeur. Le nouvel environnement devient l'environnement courant pour les expressions suivantes.

3.1 Code

if (strcmp(name, "let") == 0) {
    Sexp *sym = args->car;
    Sexp *val = evaluer_env(
        args->cdr->car, env);
    Env *local = env_new(*env);
    local->bindings = sexp_cons(
        sexp_cons(
            sexp_copie(sym),
            sexp_cons(stored, NULL)),
        local->bindings);
    *env = local;
    return sexp_copie(stored);
}

Étapes

  1. Évaluer l'expression expr dans l'env courant
  2. Créer un nouvel Env dont le parent est l'env courant
  3. Ajouter la liaison (sym . val) dans le nouvel env
  4. Remplacer l'env courant par le nouvel env
  5. Retourner une copie de la valeur

3.2 Exemple pas à pas

$ ./let '(let x 42)' '(+ x 1)' '(let y (* x 2))'
; État initial       : env: [0] ()
; (let x 42)          → 42  | env: [0] (x 42) parent: [1] ()
; (+ x 1)             → 43  | env inchangé
; (let y (* x 2))     → 84  | env: [0] (y 84) parent: [1] (x 42) parent: [2] ()

Chaque let empile une portée. L'environnement est une pile de dictionnaires. La recherche commence toujours par la portée la plus récente.

⚠️ Attention : let ne prend que deux arguments : (let symbole expression). Ce n'est pas le let de Scheme qui permet d'en lier plusieurs à la fois. Si vous voulez lier plusieurs variables, imbriquez les let ou utilisez begin.

4. set! — muter une liaison

(set! x 43) modifie la valeur de la liaison existante la plus proche dans la chaîne d'environnements.

4.1 Deux cas de mutation

set! a deux comportements selon le type de la valeur stockée :

Cas 1 — valeur simple : la liaison est remplacée dans l'alist de la portée où x a été trouvé.
;; mutation d'une liaison simple
(let x 42)    ; x → 42
(set! x 100)  ; x → 100 (remplace dans l'alist)
Cas 2 — cellule mutable : la Cell est modifiée in-situ. Toutes les références à cette cellule voient la modification.
;; mutation d'une cellule
(let c (cell 10))  ; c → #<cell:10>
(set! c 42)        ; cell:10 → cell:42
(cell-ref c)       ; → 42

4.2 Détection dans le code

for (Env *e = *env; e; e = e->parent) {
    Sexp *v = cherche(sym, e->bindings);
    if (v) {
        if (v->type == SEXPR_CELL) {
            v->cell->value = new_val->entier;
            sexp_free(new_val);
        } else {
            /* remplace dans l'alist */
        }
        return ...;
    }
}
/* si on arrive ici : x n'existe dans aucun env */
fprintf(stderr, "ERREUR: set! symbole inconnu\n");
⚠️ set! ne crée pas de liaison. Si x n'existe dans aucun environnement de la chaîne, c'est une erreur. C'est délibéré : cela évite les bugs silencieux où une faute de frappe créerait une nouvelle variable au lieu de muter une existante.

5. Fermetures lexicales (closures)

Une closure est une fonction accompagnée de l'environnement dans lequel elle a été créée. C'est le mécanisme qui permet de « capturer » des variables.

5.1 Représentation

Dans let.c, une closure est représentée par une liste S-expression :

(fun (params) corps snapshot_env)

Avec les accesseurs suivants :

static int is_closure(Sexp *v) {
    return v && v->type == SEXPR_CONS
        && v->car->type == SEXPR_SYM
        && strcmp(v->car->symbole, "fun") == 0;
}

static Sexp *closure_params(Sexp *c) { return c->cdr->car; }
static Sexp *closure_body(Sexp *c)   { return c->cdr->cdr->car; }
static Sexp *closure_env(Sexp *c)   { return c->cdr->cdr->cdr->car; }

5.2 Snapshot d'environnement

Quand on crée une closure, on fige la chaîne d'environnements. Le snapshot est une copie profonde des bindings, stockée dans la closure.

static Sexp *env_snapshot(Env *env) {
    if (!env) return NULL;
    return sexp_cons(
        sexp_copie(env->bindings),
        env_snapshot(env->parent));
}
static Env *env_from_snapshot(Sexp *s) {
    if (!s) return NULL;
    Env *e = env_new(env_from_snapshot(s->cdr));
    e->bindings = sexp_copie(s->car);
    return e;
}
Clé de voûte : sexp_copie pour un SEXPR_CELL ne crée pas une nouvelle cellule, il partage la même en incrémentant le compteur de références. Ainsi, une cellule mutable capturée dans une closure reste partagée avec l'environnement d'origine. C'est ce qui permet les objets par passage de messages.

5.3 Application d'une closure

L'application d'une closure se fait en trois étapes :

  1. Reconstruire la chaîne d'env depuis le snapshot
  2. Lier les paramètres aux arguments via zip
  3. Évaluer le corps dans le nouvel env
/* extrait de evaluer_env — application de closure */
if (is_closure(op)) {
    Sexp *params = closure_params(op);
    Sexp *body   = closure_body(op);
    Env *saved   = env_from_snapshot(closure_env(op));
    Env *local   = env_new(saved);  /* nouvelle portée */
    local->bindings = zip(params, args);
    return evaluer_env(body, &local);
}
L'application d'une closure, c'est comme ouvrir une capsule temporelle : on sort le snapshot d'env, on y ajoute les paramètres, et on exécute le corps comme si on était revenu au moment de la définition. #nostalgieLexicale

6. lambda & function

6.1 lambda — fonction anonyme

(lambda (x) (+ x 1)) crée une closure anonyme et la retourne. Elle peut être appelée immédiatement ou stockée dans une variable.

;; appel immédiat d'une lambda
$ ./let '((lambda (x) (* x 2)) 21)'
→ 42

;; stocker une lambda dans une variable
$ ./let '(let double (lambda (x) (* x 2)))' '(double 21)'
→ 42

6.2 function — fonction nommée

(function f (x) (+ x 1)) crée une closure et l'enregistre dans l'environnement courant sous le nom f. Le corps peut faire référence à f pour la récursion.

if (strcmp(name, "function") == 0) {
    Sexp *sym   = args->car;
    Sexp *params = args->cdr->car;
    Sexp *body  = args->cdr->cdr->car;
    Sexp *clos = make_closure(params, body, *env);
    /* enregistre dans l'env courant */
    (*env)->bindings = sexp_cons(
        sexp_cons(sym, sexp_cons(clos, NULL)),
        (*env)->bindings);
    return sexp_copie(clos);
}

Exemple : factorielle récursive

$ ./let '(function fact (n)
    (if (eq? n 1) 1
      (* n (fact (- n 1)))))'
  '(print (fact 5))'
120

La récursion fonctionne car fact est dans l'environnement quand le corps de la fonction s'exécute. Au moment de l'appel, on a reconstruit l'environnement depuis le snapshot, qui contient déjà la liaison fact.

⚠️ Ordre important : la closure est d'abord créée avec make_closure, puis ajoutée à l'env. Pendant la création de la closure, fact n'est pas encore dans l'env — mais ce n'est pas un problème car le snapshot est pris après.

7. Cellules mutables

Une cellule est une boîte mutable sur le tas. Elle permet de partager une valeur modifiable entre plusieurs environnements (ou plusieurs closures).

7.1 Nouveau type : SEXPR_CELL

typedef struct Cell {
    int value;  /* valeur stockée */
    int refs;   /* compteur de références */
} Cell;

Cell *cell_new(int v) {
    Cell *c = malloc(sizeof(Cell));
    c->value = v;
    c->refs = 1;
    return c;
}
/* sexp_copie pour SEXPR_CELL :
   partage la même Cell, incrémente refs */
if (s->type == SEXPR_CELL && s->cell) {
    s->cell->refs++;
    return sexp_cell(s->cell);
}

/* sexp_free pour SEXPR_CELL :
   décrémente refs, libère si 0 */
if (s->type == SEXPR_CELL && s->cell
    && --s->cell->refs == 0)
    free(s->cell);

7.2 Utilisation

;; créer une cellule
(cell 42)       → #<cell:42>

;; lire la valeur
(cell-ref c)    → 42

;; muter avec set!
(set! c 100)    ; la cellule contient maintenant 100
Partage : quand une cellule est capturée dans deux closures via env_snapshot, les deux closures partagent la même Cell. Une mutation via l'une est immédiatement visible par l'autre. C'est exactement ce dont on a besoin pour les objets.

8. Objets par passage de messages

Une closure + une cellule mutable = un objet. Le message est passé comme argument (un symbole) que la closure interprète avec eq?.

8.1 Compteur

(function make-compteur (init)
  (let c (cell init))
  (lambda (msg)
    (if (eq? msg 'inc)
        (begin (set! c (+ (cell-ref c) 1)) (cell-ref c))
      (if (eq? msg 'value) (cell-ref c)
        (if (eq? msg 'reset)
            (begin (set! c 0) (cell-ref c))
          0)))))

Utilisation

$ ./let '(let c (make-compteur 100))'
  '(c 'inc)'  '(c 'inc)'  '(c 'value)'
101
102
102

Deux compteurs indépendants

$ ./let '(let a (make-compteur 0))'
  '(let b (make-compteur 50))'
  '(a 'inc)'  '(b 'inc)'  '(a 'inc)'  '(b 'inc)'
1
51
2
52

8.2 Analyse

Les puristes de la POO vont crier au sacrilège. Mais regardez bien : vous avez des objets, de l'encapsulation (la cellule est privée), du polymorphisme (le message est interprété par la closure). Le pattern « objet par closure » est plus vieux que Java et tient dans 15 lignes de LISP.

8.3 Banque — un objet avec état et paramètres

(function make-bank (initial)
  (let solde (cell initial))
  (let historique (cell (list)))
  (lambda (msg mt)
    (if (eq? msg 'deposit)
        (begin
          (set! solde (+ (cell-ref solde) mt))
          (cell-ref solde))
      (if (eq? msg 'withdraw)
          (if (>= (cell-ref solde) mt)
              (begin
                (set! solde (- (cell-ref solde) mt))
                (cell-ref solde))
            0)  ; refusé
        (if (eq? msg 'balance) (cell-ref solde)
          0)))))

Ici, le message peut porter un paramètre (mt pour le montant). La condition (>= (cell-ref solde) mt) implémente la vérification de solde suffisant.

9. Listes : list car cdr cons null?

Les listes sont déjà représentées en interne par les SEXPR_CONS de la bibliothèque. On ajoute simplement des mots-clés au langage pour les construire et les décomposer.

9.1 Nouvelles formes spéciales

FormeComportementExempleRésultat
(list a b c …)Construit une liste(list 1 2 3)(1 2 3)
(car lst)Premier élément(car (list 10 20))10
(cdr lst)Reste de la liste(cdr (list 1 2 3))(2 3)
(cons a b)Ajoute en tête(cons 1 (list 2 3))(1 2 3)
(null? x)Test de liste vide(null? (list))1

9.2 Implémentation de car (extrait)

if (strcmp(name, "car") == 0) {
    Sexp *v = evaluer_env(args->car, env);
    if (!v || v->type != SEXPR_CONS) {
        fprintf(stderr,
            "ERREUR: car attend une paire\n");
        exit(1);
    }
    Sexp *r = sexp_copie(v->car);
    sexp_free(v);
    return r;
}

9.3 Combiner listes et fonctions

Avec la récursion, on peut implémenter des algorithmes classiques :

;; longueur d'une liste
(function len (lst)
  (if (null? lst) 0
    (+ 1 (len (cdr lst)))))

;; map : appliquer f à chaque élément
(function map (f lst)
  (if (null? lst) (list)
    (cons (f (car lst)) (map f (cdr lst)))))

;; filter : garder les éléments satisfaisant pred
(function filter (pred lst)
  (if (null? lst) (list)
    (if (pred (car lst))
        (cons (car lst) (filter pred (cdr lst)))
      (filter pred (cdr lst)))))
✏️ Exercice : Testez ces fonctions dans le REPL : ./let '(function len (lst) …)' '(len (list 10 20 30))'. Combien de récursions sont nécessaires pour calculer la longueur d'une liste de n éléments ?

10. Transpiler LISP → Python

Le langage est suffisamment complet pour être traduit vers Python. Le fichier transpile.c (environ 360 lignes) implémente un transpileur qui parcourt l'AST et émet du code Python 3.

10.1 Principe

int main(int argc, char *argv[]) {
    for (int i = 1; i < argc; i++) {
        Sexp *s = sexp_parse(argv[i]);
        transpile(s, 0);  /* 0 = context statement */
        printf("\n");
        sexp_free(s);
    }
}

La fonction récursive transpile(s, in_expr) examine le type de s :

10.2 Correspondances

LISPPython
(+ a b)a + b
(let x 42)x = 42
(x := 42) en expression
(lambda (x) body)lambda x: body
(function f (x) body)def f(x): return body
(if c t e)t if c else e
(quote (a b))['a', 'b']
(list a b c)[a, b, c]
(car lst)lst[0]
(cdr lst)lst[1:]
(cons a b)[a] + b
(null? x)x == [] or x is None
(display x)print(x, end='')
(print x)print(x)
(begin a b c)a; b; return c (dans def)
(a, b, c)[-1] (dans lambda)
foo-bar?foo_bar_ (sanitized)

10.3 Gestion du begin dans les fonctions

Le begin pose un problème : en Python, un lambda ne peut contenir qu'une expression, pas une séquence d'instructions. Le transpileur utilise deux stratégies :

Dans function (def)

;; LISP
(function f (x)
  (begin
    (display x)
    (* x 2)))

;; Python
def f(x):
    print(x, end='')
    return x * 2

Dans lambda

;; LISP
(lambda (x)
  (begin
    (display x)
    (* x 2)))

;; Python (tuple trick)
lambda x: (print(x, end=''),
           x * 2)[-1]
Le tuple trick : (a, b, c)[-1] évalue a, b, c en séquence, puis retourne le dernier élément. C'est un moyen de simuler un bloc dans une expression Python.

10.4 Exemples d'utilisation

# Factorielle récursive
$ ./transpile '(function fact (n) (if (eq? n 1) 1
    (* n (fact (- n 1)))))' '(print (fact 5))' | python3
120

# Map et filter
$ ./transpile '(function map (f lst) …)' \
    '(function even (x) (eq? 0 (% x 2)))' \
    '(print (filter even (list 1 2 3 4 5 6)))' | python3
[2, 4, 6]

# Générer du Python et l'enregistrer
$ ./transpile '(let x 42)' '(+ x 1)' > script.py
$ python3 script.py

11. Exercices

Niveau ★☆☆

✏️ Ex. 1 — double tout (★) : Écrivez une fonction double-all qui prend une liste et retourne une liste où chaque élément est multiplié par 2. Testez avec (double-all (list 1 2 3)).
✏️ Ex. 2 — compte à rebours (★) : Écrivez une fonction récursive countdown qui prend n et affiche les nombres de n à 0 avec display, puis print "BOOM". Astuce : le cas de base est n < 0.
✏️ Ex. 3 — tester le transpileur (★) : Écrivez un petit programme LISP avec let, lambda, if, et list. Transpilez-le avec ./transpile … | python3 et vérifiez que le résultat est identique à ./let ….

Niveau ★★☆

✏️ Ex. 4 — pair? (★★) : Écrivez (function pair? (x) …) qui teste si x est une paire. Utilisez-la avec filter pour extraire les paires d'une liste mixte.
✏️ Ex. 5 — accumulateur (★★) : Implémentez make-accumulator : (let a (make-accumulator 10)) (a 5) → 15 (a 3) → 18.
✏️ Ex. 6 — append (★★) : Implémentez (function append (a b) …) qui concatène deux listes. Testez : (append (list 1 2) (list 3 4)) → (1 2 3 4).

Niveau ★★★

✏️ Ex. 7 — banque (★★★) : Créez make-bank avec 'deposit, 'withdraw, 'balance. 'withdraw doit refuser si solde insuffisant (retourner 0). Ajoutez 'history qui retourne la liste des opérations sous forme de paires (type . montant).
✏️ Ex. 8 — merge sort (★★★) : Implémentez un tri fusion sur les listes. Vous aurez besoin de split (partager une liste en deux), merge (fusionner deux listes triées), et sort (récursif).

Allez plus loin

Le fichier let.c est le programme le plus complexe du cours — environ 580 lignes de C qui implémentent un langage de programmation complet. Prenez le temps de le lire et de le comprendre. Voici quelques pistes pour aller plus loin :

Ce que vous avez construit, c'est un langage de programmation qui tourne sur votre machine. Pas un jouet — un vrai langage avec des variables, des fonctions, des closures, des objets et un transpileur. La prochaine fois qu'on vous dit que le C c'est juste pour les systèmes embarqués, montrez-leur let.c. Et souriez.
📄 Fichiers clés : let.c (évaluateur), transpile.c (transpileur Python), sexpression.h / sexpression.c (bibliothèque), lang-ref.html (référence rapide).
🛠️ Commandes : make let-run · make transpile-run · ./let (REPL) · ./transpile '(+ 1 2)' | python3