Cours 6 — Persistance, bibliothèque & FFI

Sauvegarder/charger l'environnement, écrire une bibliothèque fonctionnelle en LISP (prolog.lisp), appeler du C depuis LISP, et appeler LISP depuis le C.

🎯 Objectif : transformer l'interprète LISP en un environnement de développement digne de ce nom — avec de la persistance, une bibliothèque standard, et une passerelle vers le C. Bref, on arrête de jouer, on construit un vrai langage.

1. Où en sommes-nous ?

Le cours 5 nous a donné let.c : un interprète LISP complet avec environnements chaînés, closures, cellules mutables, objets par passage de messages, listes et transpilation Python. main() charge prolog.lisp au démarrage et enregistre quelques fonctions natives.

Mais il manque encore des briques essentielles pour un langage pratique :

Jusqu'ici, notre interprète LISP était comme un ordinateur sans disque dur : on pouvait calculer, créer des objets, mais au redémarrage tout disparaissait. On va lui ajouter un SSD. Un SSD en C, évidemment.

2. Persistance : save & load

Les formes spéciales (save "fichier") et (load "fichier") permettent respectivement de sérialiser l'environnement courant sur le disque et de recharger un fichier de code LISP.

2.1 save — sérialiser l'environnement

La fonction file_save (let.c:730) parcourt la chaîne d'environnements de la portée globale à la portée locale et écrit chaque liaison sous forme d'expression LISP lisible :

Pseudo-code

;; si l'env contient :
[x 42] → [y (cell 10)] → [fact (fun ...)]

;; save génère :
(let x 42)
(let y (cell 10))
(function fact (n)
  (if (eq? n 1) 1
    (* n (fact (- n 1)))))

Code C (extrait)

static void file_save(const char *filename, Env *env) {
    FILE *f = fopen(filename, "w");
    /* collecte tous les niveaux */
    Env *frames[1024];
    int nf = 0;
    for (Env *e = env; e && nf < 1024; e = e->parent)
        frames[nf++] = e;
    /* écrit du plus externe au plus interne */
    for (int i = nf - 1; i >= 0; i--) { ... }
    fclose(f);
}

Le fichier produit est du LISP pur et dur : chaque variable est écrite avec let, chaque fermeture avec function, chaque cellule avec cell. Le résultat est à la fois exécutable et lisible par un humain.

2.2 load — lire et exécuter un fichier

La fonction file_load (let.c:647) lit un fichier texte, le découpe en S-expressions équilibrées en gérant les parenthèses et les commentaires ;, puis les évalue une par une :

static Sexp *file_load(const char *filename, Env **env) {
    FILE *f = fopen(filename, "r");
    /* lit tout le fichier en mémoire */
    fseek(f, 0, SEEK_END);
    long fsize = ftell(f);
    fseek(f, 0, SEEK_SET);
    char *buf = malloc((size_t)fsize + 2);
    fread(buf, 1, (size_t)fsize, f);
    buf[n] = '\n';
    buf[n + 1] = '\0';
    fclose(f);
    /* découpage en sexps équilibrées */
    while (buf[i]) {
        /* saute blancs et commentaires */
        /* repère une sexp ( ... ) ou un atome */
        Sexp *expr = sexp_parse(buf + start);
        result = evaluer_env(expr, env);
        sexp_free(expr);
    }
    return result;
}
Remarque : file_load ne se contente pas de parser — il exécute chaque expression au fur et à mesure. Cela signifie qu'un fichier LISP peut contenir des définitions de fonctions, des calculs, des affichages… comme un script shell.

2.3 Utilisation

$ ./let '(let x 42)' '(let y (cell 10))' '(save "session.lisp")'
; → 1

$ cat session.lisp
(let x 42)
(let y (cell 10))

$ ./let '(load "session.lisp")' '(print x)' '(print (cell-ref y))'
  -> 42
  -> 10
💡 Cas des closures : les fonctions définies avec function sont sérialisées correctement. Le snapshot d'environnement n'est pas conservé — au rechargement, la closure sera recréée dans l'environnement courant. C'est un choix délibéré (simplicité > fidélité).
save et load, c'est votre sauvegarde de partie dans un jeu vidéo. Sauf que le jeu, c'est votre environnement de programmation, et que le « Game Over » c'est un segfault. Sauvegardez souvent.

3. prolog.lisp — la bibliothèque standard

Au démarrage, let charge automatiquement prolog.lisp s'il existe (let.c:844-847). Ce fichier est notre bibliothèque standard : un ensemble de fonctions écrites en LISP qui étendent le langage.

Dans main()

{
    FILE *f = fopen("prolog.lisp", "r");
    if (f) { fclose(f);
             file_load("prolog.lisp", &env); }
}

Rôle

Comme un #include <stdlib.h> en C, ou comme le noyau d'un OS qui fournit des appels système. prolog.lisp est la première chose chargée — une API de base pour tout programme LISP qui suit.

3.1 Arithmétique

;; fact — version flottante pour grands nombres
(function fact (n)
  (if (< n 2) 1.0 (* n (fact (- n 1)))))

;; puissance : x^n
(function puissance (x n)
  (if (eq? n 0) 1 (* x (puissance x (- n 1)))))

3.2 Trigonométrie (développement limité)

;; cos(x) par série de Taylor (10 termes)
(function cos-iter (x k n sign)
  (if (> k n) 0
    (+ (* sign (/ (puissance x (* 2 k)) (fact (* 2 k))))
       (cos-iter x (+ k 1) n (* -1 sign)))))

(function cos (x)
  (cos-iter x 0 10 1))
📐 Détail : cos(x) = Σk=0n (−1)k · x2k / (2k)!. La version dans prolog.lisp utilise la récursion terminale et 10 termes pour une précision raisonnable. Pas de double, pas de bibliothèque math — juste des S-expressions et de la récursion.

3.3 Générateurs de listes

;; range : [start, stop] avec un pas
(function range (start step stop)
  (if (> start stop) (quote ())
      (cons start (range (+ start step) step stop))))

;; iota : [0, 1, ..., n-1]
(function iota (n)
  (range 0 1 (- n 1)))

;; repeat : n copies de x
(function repeat (x n)
  (if (eq? n 0) (quote ())
      (cons x (repeat x (- n 1)))))

3.4 Accès et transformation

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

;; take : les n premiers
(function take (n xs)
  (if (or (eq? n 0) (null? xs)) (quote ())
      (cons (car xs) (take (- n 1) (cdr xs)))))

;; drop : enlève les n premiers
(function drop (n xs)
  (if (or (eq? n 0) (null? xs)) xs
      (drop (- n 1) (cdr xs))))

;; reverse : inversion
(function reverse (xs)
  (if (null? xs) (quote ())
      (append (reverse (cdr xs)) (list (car xs)))))

;; append : concaténation
(function append (xs ys)
  (if (null? xs) ys
      (cons (car xs) (append (cdr xs) ys))))

3.5 Higher-Order Functions

;; map : applique f à chaque élément
(function map (f vs)
  (if (null? vs) (quote ())
      (cons (f (car vs)) (map f (cdr vs)))))

;; filter : garde les éléments qui satisfont pred
(function filter (pred xs)
  (if (null? xs) (quote ())
      (if (pred (car xs))
          (cons (car xs) (filter pred (cdr xs)))
          (filter pred (cdr xs)))))

;; zip : combine deux listes en paires
(function zip (vs ws)
  (if (or (null? vs) (null? ws)) (quote ())
      (cons (list (car vs) (car ws))
            (zip (cdr vs) (cdr ws)))))

;; foldl : réduction gauche
(function foldl (f init xs)
  (if (null? xs) init
      (foldl f (f init (car xs)) (cdr xs))))

;; foldr : réduction droite
(function foldr (f init xs)
  (if (null? xs) init
      (f (car xs) (foldr f init (cdr xs)))))

;; sum / product : raccourcis
(function sum (xs)    (foldl + 0 xs))
(function product (xs) (foldl * 1 xs))

3.6 Composition et calcul différentiel

;; compose : (f ∘ g)(x) = f(g(x))
(function compose (f g)
  (lambda (x) (f (g x))))

;; deriv : dérivée numérique
(function deriv (f h)
  (lambda (x) (/ (- (f (+ x h)) (f (- x h))) (* 2 h))))

;; prédicats : any / all
(function any (pred xs)
  (if (null? xs) 0
      (if (pred (car xs)) 1 (any pred (cdr xs)))))

(function all (pred xs)
  (if (null? xs) 1
      (if (pred (car xs)) (all pred (cdr xs)) 0)))
🔑 Prolog.lisp = noyau : Ce fichier est chargé au lancement comme un init.lisp ou un .bashrc. Il définit les primitives de base sur lesquelles tout le reste s'appuie. C'est exactement le modèle des systèmes d'exploitation : un micro-noyau (l'interprète) et des services système (prolog.lisp).
prolog.lisp, c'est comme le dossier /etc de votre OS LISP : au démarrage, tout est chargé, tout est prêt. Sauf qu'au lieu de configurer des démons, on définit map, filter et foldl. C'est plus propre, et ça consomme moins de RAM.

4. Fonctions natives — le pont C → LISP

Parfois, le LISP pur ne suffit pas : calculs trigonométriques, nombres aléatoires, opérations système… Pour ces cas, on a besoin d'appeler du C depuis LISP. C'est le rôle des fonctions natives.

4.1 Le type SEXPR_NATIVE

La bibliothèque sexpression.h définit un nouveau type :

enum { SEXPR_INT, SEXPR_SYM, SEXPR_CONS,
       SEXPR_CELL, SEXPR_FLOAT, SEXPR_NATIVE };

typedef struct Sexp {
    int    type;
    /* ... */
    void  *native_fn;  /* SEXPR_NATIVE */
} Sexp;

Une fonction native est une fonction C qui a cette signature :

typedef Sexp *(*NativeFn)(Sexp *args, Env **env);

Elle reçoit les arguments (non évalués) et l'environnement courant, et retourne une S-expression. C'est aussi simple que ça.

4.2 Enregistrement d'une native

La fonction register_native crée une S-expression de type SEXPR_NATIVE et l'ajoute aux bindings :

static void register_native(Env *env,
                           const char *name,
                           NativeFn fn) {
    env->bindings = sexp_cons(
        sexp_cons(sexp_sym(name),
                  sexp_cons(
                      sexp_native(name, (void*)fn),
                      NULL)),
        env->bindings);
}

Fonction sin

static Sexp *native_sin(Sexp *args, Env **env) {
    if (!args || args->cdr) {
        fprintf(stderr,
            "ERREUR: sin attend "
            "1 argument\n");
        exit(1);
    }
    Sexp *v = evaluer_env(
        args->car, env);
    double x = (v->type == SEXPR_FLOAT)
               ? v->reel
               : (double)v->entier;
    sexp_free(v);
    return sexp_float(sin(x));
}

Fonction sqrt

static Sexp *native_sqrt(Sexp *args, Env **env) {
    if (!args || args->cdr) {
        fprintf(stderr,
            "ERREUR: sqrt attend "
            "1 argument\n");
        exit(1);
    }
    Sexp *v = evaluer_env(
        args->car, env);
    double x = (v->type == SEXPR_FLOAT)
               ? v->reel
               : (double)v->entier;
    sexp_free(v);
    return sexp_float(sqrt(x));
}

4.3 La native random

static Sexp *native_random(Sexp *args, Env **env) {
    (void)args; (void)env;
    return sexp_float(
        (double)rand() / (double)RAND_MAX);
}

4.4 Init dans main()

int main(int argc, char *argv[]) {
    Env *env = env_new(NULL);

    /* enregistre les fonctions natives */
    register_native(env, "random", native_random);
    register_native(env, "sqrt",   native_sqrt);
    register_native(env, "sin",    native_sin);

    /* charge prolog.lisp s'il existe */
    FILE *f = fopen("prolog.lisp", "r");
    if (f) { fclose(f);
             file_load("prolog.lisp", &env); }
    ...
}
NativeSignature LISPImplémentation CNotes
random(random)rand() / RAND_MAXRetourne un float [0, 1)
sqrt(sqrt x)sqrt(x) (math.h)Racine carrée, toujours float
sin(sin x)sin(x) (math.h)Sinus, toujours float

4.5 Évaluation d'une native

Quand l'évaluateur rencontre un symbole qui correspond à une fonction native, il appelle fn->native_fn(args, env) :

/* dans evaluer_env */
Sexp *fn = env_lookup(*env, op);
if (fn != (Sexp*)-1 && fn != NULL) {
    if (is_closure(fn))
        return apply_closure(fn, args, env);
    if (is_native(fn))
        return ((NativeFn)fn->native_fn)(args, env);
}
Même mécanisme : que ce soit une closure LISP ou une fonction native C, le code appelant est le même. Le dispatch se fait par type (is_closure / is_native). C'est la beauté du polymorphisme de type en C avec une union (ou plutôt un void*).
Les fonctions natives, c'est le laissez-passer diplomatique entre le monde LISP et le monde C. Le LISP peut appeler sin comme si c'était une fonction LISP — mais derrière, c'est du C pur, rapide, sans récursion. Le meilleur des deux mondes, comme un croissant fourré au Nutella.

5. Embedding — appeler LISP depuis le C

La réciproque existe aussi : lorsque les traitements s'expriment plus simplement en LISP, on peut embarquer l'interprète dans un programme C. C'est l'embedding.

5.1 tabulator.c — un tabulateur en C + LISP

Le fichier tabulator.c (48 lignes) montre comment un programme C peut déléguer un calcul à l'interprète LISP via popen :

int main(int argc, char *argv[]) {
    /* Construit une commande let avec range + map */
    int n = snprintf(cmd, sizeof(cmd),
        "./let '(map (lambda (x) (cons x %s))"
        " (range %s %s %s))'",
        argv[1], argv[2], argv[3], argv[4]);

    /* Exécute let et lit le résultat */
    FILE *fp = popen(cmd, "r");
    /* Extrait les lignes "→ ..." */
    while (fgets(buf, sizeof(buf), fp)) {
        char *p = strstr(buf, "  -> ");
        if (p) fputs(p + 4, stdout);
    }
    ...
}

Exemple d'utilisation

$ ./tabulator '(+ (* x x) (* 3 x))' -2 0.5 2
(-2 . -2)
(-1.5 . -2.25)
(-1 . -2)
(-0.5 . -1.25)
(0 . 0)
(0.5 . 0.75)
(1 . 4)
(1.5 . 6.75)
(2 . 10)

Ce que fait le C

Ce que fait le LISP

5.2 Variante : liaison directe (librairie)

Une autre approche (plus élégante) consiste à lier statiquement l'interprète LISP dans le programme C, en utilisant directement les fonctions de sexpression.h/.c :

#include "sexpression.h"

int main() {
    // Construire une expression LISP en C
    Sexp *expr = sexp_parse("(+ 1 2)");

    // L'évaluer avec evaluer (sans env)
    Sexp *res = evaluer(expr, NULL);
    sexp_print(res);  // → 3

    sexp_free(res);
    sexp_free(expr);
    return 0;
}

Cette approche est utilisée par plusieurs programmes du cours : evalenv.c, mapk.c, filterk.c, table.c, etc. Tous lient sexpression.o et manipulent les S-expressions directement depuis le C.

FichierUtilisation de LISP depuis C
tabulator.cAppelle ./let via popen
evalenv.cLie sexpression.o, utilise evaluer
mapk.cCrée des S-expressions en C, appelle evaluer
filterk.cFiltre une liste avec un prédicat LISP
table.cZip + eval pour filtrer une table
project.cProjection d'expressions LISP
💡 Quand utiliser l'embedding ?
⚠️ Attention : l'embedding via popen crée un sous-processus à chaque appel. C'est lent. Pour des appels fréquents, préférez la liaison directe via sexpression.o. Le tabulator s'en sort parce qu'il ne fait qu'un seul appel — mais pour une boucle chaude, ce serait désastreux.

6. Architecture complète

Avec ces quatre briques (persistance, bibliothèque, FFI, embedding), l'architecture de notre environnement LISP forme une pile cohérente :

🔝 Programme utilisateur — votre code LISP
📚 prolog.lisp — bibliothèque standard (map, filter, fold, range, cos…)
💾 save / load — persistance de l'environnement
Fonctions natives — random, sqrt, sin (C → LISP)
🧩 let.c (interprète) — closures, envs, cellules, REPL
📦 sexpression.c/h — parseur, types, constructeurs
🖥️ C (langage hôte) — programmes C qui embarquent l'interprète

7. Exercices

Niveau ★☆☆

✏️ Ex. 1 — sauvegarder/recharger (★) : Lancez ./let, définissez quelques variables avec let et function, puis (save "test.lisp"). Quittez, relancez, faites (load "test.lisp") et vérifiez que les variables existent toujours.
✏️ Ex. 2 — utiliser prolog.lisp (★) : Calculez (map (lambda (x) (* x x)) (iota 10)). Que retourne (foldl + 0 (iota 100)) ? Et (cos 3.14159) ?
✏️ Ex. 3 — tester les natives (★) : Évaluez (random) plusieurs fois. Calculez (sqrt 144) et (sin 0). Que se passe-t-il avec (sqrt -1) ?

Niveau ★★☆

✏️ Ex. 4 — dérivée numérique (★★) : Utilisez la fonction deriv de prolog.lisp pour calculer la dérivée de (lambda (x) (* x x)) en x = 3. La valeur exacte est 6. L'approximation avec h = 0.001 donne quoi ?
✏️ Ex. 5 — tabulateur manuel (★★) : En utilisant directement ./let (sans tabulator), générez une table de valeurs pour sin(x) sur [0, π] avec un pas de 0.1. Utilisez range et map.
✏️ Ex. 6 — ajouter une native (★★) : Ajoutez une fonction native cos à let.c (déjà dans math.h). Enregistrez-la dans main() et comparez ses résultats avec la version LISP de prolog.lisp. Laquelle est la plus précise ? Laquelle est la plus rapide ?

Niveau ★★★

✏️ Ex. 7 — intégration numérique (★★★) : Écrivez une fonction LISP (integral f a b n) qui calcule l'intégrale de f sur [a, b] avec n rectangles (méthode des rectangles). Utilisez range, map et sum. Testez avec (integral (lambda (x) (* x x)) 0 1 1000) (≈ 0.333).
✏️ Ex. 8 — un mini-sauveur de session (★★★) : Modifiez let.c pour que le REPL sauvegarde automatiquement l'environnement dans .let_session à chaque let ou function, et le recharge au démarrage. Gère le cas où le fichier n'existe pas.
✏️ Ex. 9 — native générique (★★★) : Ajoutez une forme spéciale (define-native nom args body) qui permet de définir une fonction native à l'exécution (sans recompiler). Le corps serait une chaîne C compilée avec libtcc ou similaire. (Difficile — nécessite un compilateur JIT.)

Allez plus loin

Ce cours marque un tournant : on passe de la construction d'un langage à l'industrialisation de l'environnement. Voici des pistes pour continuer :

Vous avez maintenant un langage de programmation avec : une bibliothèque standard (prolog.lisp), de la persistance (save/load), une FFI (fonctions natives), et de l'embedding (tabulator, evalenv). Bref, vous avez réinventé Python. En C. En 800 lignes. Ne dites pas à Guido van Rossum.
📄 Fichiers clés : let.c (évaluateur + save/load + natives), prolog.lisp (bibliothèque standard), tabulator.c (embedding C → LISP), sexpression.h / sexpression.c (bibliothèque).
🛠️ Commandes : ./let '(save "f.lisp")' · ./let '(load "f.lisp")' · ./tabulator '(+ x x)' 0 1 10