Table des matières

TD 3 : Pf en JS

Objectifs du TD

À la fin de ce TD, vous saurez :

  • définir précisément la pureté, l'immutabilité et la transparence référentielle, et les vérifier sur du code réel ;
  • identifier les effets de bord cachés (y compris ceux qui sont subtils) ;
  • écrire des fonctions d'ordre supérieur, des closures et des fonctions curryfiées ;
  • composer des fonctions pour construire des traitements de données complexes ;
  • refactoriser du code impératif en code fonctionnel.

Prérequis : syntaxe JS (fonctions fléchées, destructuration, spread).
Consigne générale : pour chaque exercice, écrivez des tests avec console.assert (ou Jest si disponible). Un code sans test n'est pas considéré comme terminé.

Mini-référence : mutateurs vs non-mutateurs sur les tableaux

Mutent le tableau Ne mutent pas
push, pop, shift, unshift, splice, sort, reverse, fill map, filter, reduce, slice, concat, […arr], toSorted, toReversed, with

sort et reverse sont des pièges classiques.

Exercice 1 : Fonctions pures

Définition à tester : une fonction est pure si (1) pour les mêmes arguments elle retourne toujours le même résultat, et (2) elle ne produit aucun effet observable en dehors de son résultat.

1.1 - Écrire une fonction pure

const input = [1, 2, 3];
const output = double(input);
console.assert(JSON.stringify(output) === "[2,4,6]");
console.assert(JSON.stringify(input) === "[1,2,3]");  // l'entrée n'a pas changé
console.assert(output !== input);                      // c'est un nouveau tableau
console.assert(JSON.stringify(double([])) === "[]");   // cas limite

1.2 - Vérifier la pureté

Critère Respecté ? Justification
Même entrée → même sortie
Ne modifie pas ses arguments
Ne lit aucun état externe
Ne produit aucun effet observable (console, réseau, DOM, fichier…)

1.3 - Quatre fonctions impures, quatre sources d'impureté

Pour chacune des fonctions ci-dessous, identifiez la cause exacte d'impureté parmi : dépendance à un état externe, mutation d'argument, effet observable, non-déterminisme. Puis réécrivez-la en version pure.

// A
let total = 0;
function addToTotal(n) { total += n; return total; }

// B
function addItem(cart, item) { cart.push(item); return cart; }

// C
function greet(name) {
  console.log("Bonjour " + name);
  return name.length;
}

// D
function isExpired(deadline) { return new Date() > deadline; }

1.4 - Pour aller plus loin : l'impureté cachée

function normalize(users) {
  return users.map(u => { u.name = u.name.trim(); return u; });
}

Points de discussion

  • Dans 1.3, laquelle des quatre sources d'impureté est la plus difficile à repérer à la lecture du code ? Pourquoi ?
  • Une fonction qui fait console.log est-elle “dangereuse” ? Où placer les fonctions impures dans une application si on ne peut pas les supprimer ?
  • Dans 1.4, map a-t-il rendu la fonction pure ? Que dit cet exemple sur l'usage “magique” des méthodes fonctionnelles ?
  • Pourquoi une fonction pure est-elle triviale à tester, contrairement à A, B, C et D ?

Exercice 2 : Immutabilité

Objectif : produire des “versions modifiées” de données sans jamais les muter, y compris pour des structures imbriquées.

2.1 - Tableaux : les quatre opérations de base

Écrire quatre fonctions pures, sans mutateur, qui retournent un nouveau tableau :

Vérifier avec Object.freeze(arr) que votre code ne tente jamais de muter l'entrée.

2.2 - Observer la mutation

const a = [3, 1, 2];
const b = a;
b.push(4);
const c = a.sort();
console.log(a, b, c);
console.log(a === b, a === c);

2.3 - Objets : mises à jour immuables

Soit :

const user = {
  name: "Alice",
  address: { city: "Paris", zip: "75001" },
  tags: ["admin", "dev"]
};

Écrire sans muter user :

Pour chaque fonction, tester que l'original est inchangé et que les parties non modifiées sont partagées (newUser.tags === user.tags pour rename).

2.4 - Le piège de la copie superficielle

const copy = { ...user };
copy.address.city = "Lyon";
console.log(user.address.city);

2.5 - Pour aller plus loin : mise à jour profonde générique

const updated = setIn(user, ["address", "city"], "Lyon");
console.assert(updated.address.city === "Lyon");
console.assert(user.address.city === "Paris");
console.assert(updated.tags === user.tags);

Points de discussion

  • Dans 2.2, quel bug concret une référence partagée peut-elle provoquer dans une application où deux composants utilisent le même tableau ?
  • Object.freeze est-il suffisant pour garantir l'immutabilité ? Testez-le sur user.address.
  • Copier toute la structure à chaque modification est-il coûteux ? Comment le partage structurel (cf. updated.tags === user.tags) limite-t-il ce coût ? Quel lien avec Immer, Immutable.js ou Redux ?
  • Citez un cas où la mutation locale est acceptable (ex : accumulateur dans un reduce non exposé). À quelle condition préserve-t-elle la pureté vue de l'extérieur ?

Exercice 3 : Transparence référentielle

Définition : une expression est référentiellement transparente si on peut la remplacer par sa valeur sans changer le comportement du programme.

3.1 - Substitution sur des fonctions simples

add(mul(2, 3), mul(2, 3))

const x = mul(2, 3);
add(x, x)

3.2 - Détecter les ruptures de transparence

Pour chacune des fonctions suivantes, dire si elle est transparente et justifier. Tester en appelant deux fois de suite avec les mêmes arguments.

// A
let counter = 0;
const next = () => ++counter;

// B
const rand = () => Math.random();

// C
const now = () => Date.now();

// D (TAX est modifiable ailleurs dans le programme)
let TAX = 0.2;
const addTax = (price) => price * (1 + TAX);

// E
const first = (arr) => arr[0];

// F
const logAndAdd = (a, b) => {
  console.log(a);
  return a + b;
};

Pour chaque cas, reformulez : quelle information supplémentaire (hors arguments) la fonction utilise-t-elle ou produit-elle ?

3.3 - Rendre transparent

(seed * 1664525 + 1013904223) % 2**32

3.4 - Mémoïsation : l'application directe

Points de discussion

  • Pourquoi la mémoïsation n'est-elle valide que sur des fonctions référentiellement transparentes ? Lien avec 3.4.
  • En 3.1, la factorisation de mul(2, 3) est sûre. Dans un code avec effets de bord, serait-elle toujours sûre ? Quelle conséquence pour le refactoring et pour les optimisations d'un compilateur ?
  • Une fonction peut être pure sans que son appel soit “inoffensif” (ex : très coûteuse). La transparence référentielle garantit-elle les performances ?
  • Une fonction qui lit une constante globale (const TAX = 0.2) est-elle référentiellement transparente ? Et si la constante est un objet mutable ?

Exercice 4 : Fonctions d'ordre supérieur

Définition : une fonction d'ordre supérieur prend une fonction en argument et/ou retourne une fonction.

4.1 - Reconstruire les classiques

Sans utiliser les méthodes natives correspondantes, implémenter (avec une boucle ou de la récursion) :

Puis réimplémenter myMap et myFilter uniquement à partir de myReduce. Ce que vous découvrez : reduce est la primitive universelle de traitement de tableau.

4.2 - Fonctions qui retournent des fonctions (closures)

4.3 - Currying et application partielle

const add3 = (a, b, c) => a + b + c;
const formatPrice = (currency, decimals, value) => /* à compléter */;

4.4 - Composition

console.assert(slugify("  Hello World !  ") === "hello-world");

4.5 - Pièges des méthodes natives

["1", "2", "3"].map(parseInt)

4.6 - Pour aller plus loin

Points de discussion

  • Dans 4.1, qu'est-ce qui rend reduce plus “puissant” que map et filter ? Peut-on exprimer reduce avec map ?
  • Dans 4.2, où vit l'état de la closure ? Comparer avec un objet. Quand une closure est-elle un bon outil d'encapsulation ?
  • Le currying favorise-t-il la réutilisation ? Dans quel cas rend-il le code moins lisible (ordre des paramètres) ? Quelle règle pour ordonner les arguments (données en dernier) ?
  • pipe vs enchaînement de méthodes (arr.map(...).filter(...)) : avantages et limites de chaque approche ?

Exercice 5 : Synthèse : panier d'achat

Contexte : vous développez le calcul d'un panier pour un site e-commerce. Toutes les fonctions doivent être pures et sans mutation.

const products = [
  { id: 1, name: "Clavier",  price: 80,  category: "tech",   stock: 5 },
  { id: 2, name: "Souris",   price: 30,  category: "tech",   stock: 0 },
  { id: 3, name: "Livre JS", price: 40,  category: "book",   stock: 12 },
  { id: 4, name: "Cahier",   price: 5,   category: "office", stock: 100 },
  { id: 5, name: "Écran",    price: 250, category: "tech",   stock: 2 }
];

5.1 - Version impérative (point de départ)

On vous donne ce code :

let total = 0;
for (let i = 0; i < products.length; i++) {
  if (products[i].stock > 0) {
    products[i].price = products[i].price * 0.9;
    total += products[i].price;
  }
}
console.log("Total : " + total);

5.2 - Briques élémentaires pures

Écrire et tester chacune isolément :

5.3 - Assemblage

5.4 - Extensions métier

Chaque extension doit réutiliser les briques existantes, pas dupliquer du code :

{ tech: ..., book: ..., office: ... }

5.5 - Analyse de la pureté (partie rédigée)

Pour chaque fonction écrite, remplir :

Fonction Pure ? Mutation ? Transparente ? Remplaçable par sa valeur ?
applyDiscount
cartTotal
receipt
fonction d'affichage

Ensuite, démontrer la transparence référentielle de cartTotal : choisissez un appel, remplacez-le par sa valeur dans un autre calcul, et vérifiez l'équivalence.

5.6 - Pour aller plus loin

{ type: "ADD", product }
{ type: "REMOVE", id }

Points de discussion

  • Entre 5.1 et 5.3, qu'avez-vous gagné en testabilité ? Combien de tests faut-il pour la version impérative vs. pour chaque brique ?
  • Où sont passés les effets de bord ? Pourquoi les repousser à la périphérie du programme (“functional core, imperative shell”) ?
  • Si un collègue ajoute une règle de remise (ex : Black Friday), quelles fonctions faut-il modifier dans la version impérative ? Dans la version composée ?
  • La version fonctionnelle parcourt plusieurs fois le tableau (filter, map, reduce). Est-ce un problème ? Comment un transducer ou un évaluateur paresseux y répond-il ? À partir de quelle taille de données cela compte-t-il vraiment ?
  • Dans 5.6 (réducteur), pourquoi l'immutabilité rend-elle possible le “time travel debugging” et l'annulation (undo) ?