Aller au contenu principal

Paradigmes de programmation

Ce que ce chapitre apporte 6 points
  • Dire ce qu'est un paradigme de programmation, et le distinguer d'un langage.
  • Distinguer un programme qui décrit des étapes d'un programme qui décrit un résultat, sur un tableur et sur une requête SQL.
  • Dire, pour chacune des quatre familles, où vit l'état du programme et qui peut le modifier.
  • Prévoir, sans exécuter, l'effet d'une affectation, d'une copie ou d'un appel de fonction sur une liste.
  • Expliquer pourquoi une donnée à la fois partagée et modifiable produit des défauts, et chiffrer ce que coûtent les parades.
  • Choisir une famille pour un problème donné, en nommant la propriété qui décide.

Une cellule suit ses données, une variable non.

Le chapitre précédent a donné le temps de réponse moyen d'un serveur unique, W=1/(μ−λ)W = 1/(\mu - \lambda). Dans un tableur, la case qui contient =1/(B3-B2) se met à jour seule quand on change le rythme d'arrivée λ\lambda. Dans un script Python, w = 1 / (service - arrivees) suivi de arrivees = 9 laisse w à son ancienne valeur. D'où vient la différence, et que faut-il savoir d'un programme avant de lui confier des données que d'autres modifient ?

La réponse tient en une question qui revient à chaque page : où vit l'état, c'est-à-dire ce que le programme garde d'une instruction à l'autre, et qui a le droit de le changer. Chaque grande famille de programmation, ou paradigme, y répond à sa façon. Le chapitre ne récrit pas un même programme dans chaque famille : il donne à chacune un problème d'atelier où elle est chez elle, un compteur de pièces, une cuve d'huile de coupe, un journal de cotes, un registre d'arrêts de machine. Il montre ensuite ce qui arrive quand une donnée modifiable est partagée.

Il ouvre le deuxième temps du parcours : après six chapitres consacrés à décider et à mesurer, de la théorie des jeux aux files d'attente, on passe à l'écriture du programme lui-même. Le Python employé est celui du parcours Python, et chaque notion est rappelée avant de servir.

Travailler seul

Chaque notion est rappelée dans un encadré Rappel de cours, avec un petit exemple résolu, juste avant la question ou le calcul qui s'en sert. Avant chaque programme, un cadre demande de prévoir ce qu'il affichera : répondre d'abord, exécuter ensuite, et comparer. L'écart entre ce qu'on prévoyait et ce qui s'affiche est précisément ce que le chapitre enseigne. Les questions à déplier se travaillent par écrit, en quelques phrases, avant d'ouvrir la réponse.

Une cellule et une variable

Le service de paiement du chapitre précédent reçoit λ=8\lambda = 8 demandes par seconde et en traite au plus μ=10\mu = 10. Son temps de réponse moyen vaut W=1/(μ−λ)W = 1/(\mu - \lambda), soit une demi-seconde. Le même calcul, écrit deux fois.

B4fx
AB
1GrandeurValeur
2Arrivées λ (demandes/s)8
3Service μ (demandes/s)10
4Temps de réponse W (s)0,5
5  

B4 contient la formule =1/(B3-B2), et affiche 0,5. Les cellules qu'elle utilise sont teintées.

Le temps de réponse d'un serveur unique, dans un tableur. La case B4 ne contient pas un nombre mais une formule : la sélectionner la fait apparaître dans la barre.
À manipuler
Remplacer le 8 de la case B2 par 9, puis valider avec la touche Entrée : B4 passe de 0,5 à 1 sans qu'on y touche. Essayer 9,5 : le temps de réponse double encore. À 10, la case affiche une erreur de division par zéro, et c'est la condition de stabilité du chapitre précédent, λ < μ, qui se rappelle.

Le même calcul en Python demande de savoir ce que fait exactement une ligne comme w = 1 / (service - arrivees).

Rappel de cours : l'affectation, la formule

En Python, w = 1 / (service - arrivees) est une affectation : une instruction, exécutée une seule fois, au moment où le programme y arrive. Elle calcule la valeur avec ce que valent alors service et arrivees, puis colle le nom w sur le résultat. Rien ne relie ensuite w à arrivees.

Exemple résolu : après a = 2, puis b = a * 3, puis a = 5, le nom b désigne toujours 6. L'affectation est présentée dans Les bases de Python.

Dans un tableur, =A1*3 est une formule : non pas un calcul fait une fois, mais une relation entre deux cases, que le tableur maintient vraie. Exemple résolu : A1 vaut 2 et B1 contient =A1*3, B1 affiche 6 ; taper 5 dans A1 fait afficher 15 à B1.

Prévoir avant d'exécuter

  • 1.

    Le programme ci-dessous calcule w, puis passe le rythme d'arrivée à 9 et affiche w de nouveau. Quelle valeur affiche sa dernière ligne, en secondes ?

main.py
Sortie
>_ Prêt à exécuter…

Les deux lignes affichent 0,5 seconde. Ni le tableur ni Python ne se trompent : ils ne disent pas la même chose. La formule du tableur déclare une relation, et le tableur décide quand la recalculer ; le script ordonne une suite d'étapes, faites chacune une fois et dans l'ordre. Pour que w suive, il faudrait réécrire la ligne qui le calcule après chaque changement, c'est-à-dire ajouter une étape. C'est la première grande ligne de partage entre façons de programmer : décrire ce qu'on veut obtenir, ou décrire comment l'obtenir.

Définition

Un paradigme de programmation est une façon de concevoir un programme : ce qu'on y écrit (des étapes, des objets, des fonctions, un résultat voulu), et où vit ce que le programme garde d'une instruction à l'autre.

Un paradigme n'est pas un langage

Trois confusions à écarter d'emblée.

Un paradigme n'est pas un langage. Python permet d'écrire des étapes, des objets et des fonctions dans le même fichier, et le tableur, qui n'est pas un langage au sens courant, est l'exemple le plus répandu du style déclaratif. Java, pensé autour des classes, a ajouté des fonctions de première classe : des fonctions que le langage traite comme n'importe quelle autre valeur, qu'on peut ranger dans une variable, passer en argument ou rendre comme résultat. L'usage le plus courant en Python est sorted(arrets, key=lambda a: a["duree"]), qui range des arrêts de machine par durée : sorted reçoit une autre fonction, écrite sur place et sans nom, qui dit selon quoi trier. Le chapitre Bien écrire une fonction l'enseigne. La plupart des langages sont multi-paradigmes, et le choix revient à qui écrit.

Un paradigme n'est pas un niveau de compétence. Écrire en objet n'est pas « plus avancé » qu'écrire une suite d'étapes. Un script de 40 lignes enveloppé dans trois classes est plus difficile à lire, pas plus professionnel.

Ce n'est pas non plus une religion. Un vrai logiciel mélange les familles : une application de suivi de production range ses données dans une base interrogée en SQL, calcule ses indicateurs avec des fonctions, et représente chaque machine par un objet.

La carte : où vit l'état

Rappel de cours : état, modifiable, immuable

L'état d'un programme est ce qu'il garde d'une instruction à l'autre et qui peut changer : un total en cours, une liste de relevés, le niveau d'une cuve.

Une valeur est modifiable (on dit aussi mutable) quand on peut la changer sans en fabriquer une autre : une liste, un dictionnaire. Elle est immuable quand toute modification fabrique une valeur neuve : un nombre, un texte, un tuple.

Exemple résolu : après l = [14] puis l[0] = 18, la même liste contient 18. Avec t = (14,), l'instruction t[0] = 18 est refusée par Python, et t = (18,) colle le nom t sur un tuple neuf, sans toucher à l'ancien. Les listes, les dictionnaires et les tuples sont présentés dans Listes, dictionnaires et tuples.

Quatre familles se partagent la façon de programmer. Les deux premières sont deux manières d'organiser des étapes, donc deux formes de l'impératif ; les deux dernières décrivent un résultat plutôt qu'un chemin, la fonctionnelle en partie, la déclarative entièrement. Ce qui les sépare le mieux est la colonne du milieu.

FamilleLe programme écritOù vit l'étatDéjà rencontré
Procéduralela suite des étapesdans des variables que tout le programme peut changerune boucle qui accumule un total
Objetdes objets qui portent données et opérationsdans chaque objet, derrière ses méthodesles classes du parcours Python
Fonctionnelledes fonctions qui rendent des valeurs neuvesnulle part : chaque version est une valeursorted, une compréhension
Déclarativele résultat vouludans le moteur, qui choisit le cheminune formule de tableur, une requête SQL
La ligne de partage la plus utile

Elle passe entre impératif (le code décrit la suite d'étapes à exécuter) et déclaratif (le code décrit le résultat voulu, un moteur se charge des étapes). Le procédural et l'objet sont deux façons d'organiser l'impératif ; le fonctionnel penche vers le déclaratif, puisqu'une fonction pure dit ce que vaut un résultat plutôt que comment le fabriquer. La question à poser devant un programme : écrit-il comment, ou quoi ?

Vérification rapideon peut se reprendre

1.Un paradigme et un langage, c'est la même chose ?

2.Le paradigme déclaratif se distingue de l'impératif parce qu'il…

3.Une fonction reçoit les cotes du jour et rend un nouveau tuple, celui des cotes hors tolérance, sans rien modifier. Où vit l'état de ce calcul ?

Une famille par problème

Chaque famille a ses problèmes de prédilection. Les quatre qui suivent viennent du même atelier de production, mais ce sont quatre problèmes différents, choisis parce que la famille y est chez elle.

Procédural : un compteur que tout le programme modifie

En fin de poste, l'atelier veut savoir combien de pièces chaque machine a produites. Les lots arrivent un par un, et chacun s'ajoute au compteur de sa machine : une suite d'étapes qui modifient un total. C'est le terrain du procédural, qui décrit les étapes dans l'ordre où on les ferait à la main, comme les algorithmes du parcours d'algorithmique. Les données vivent dans une variable que tout le programme voit, et des fonctions la modifient.

Rappel de cours : une fonction sans return

Une fonction qui se termine sans return rend la valeur spéciale None, qui veut dire « rien ». Ce qu'elle a changé en route, une liste ou un dictionnaire, reste changé après l'appel.

Exemple résolu : avec def remettre(compteur): compteur["tour"] = 0, l'appel print(remettre(c)) affiche None, et c["tour"] vaut 0 après l'appel. Le chapitre Les fonctions distingue afficher et renvoyer.

main.py
Sortie
>_ Prêt à exécuter…

Direct, lisible, minimal. La première ligne affichée mérite qu'on s'y arrête, parce que tout le chapitre tourne autour : l'appel enregistrer("presse", 15) renvoie None, c'est-à-dire rien, et pourtant la ligne suivante montre une presse à 180 pièces. Ce que la fonction rend et ce qu'elle fait sont deux choses distinctes, et c'est la seconde qui compte ici : on l'appelle un effet de bord, toute modification qu'une fonction laisse derrière elle en dehors de ce qu'elle renvoie. Une fonction à effet de bord ne se lit pas dans son appel, seulement dans son corps.

Et une faiblesse structurelle : production est accessible et modifiable de partout. Rien n'empêche un autre bout de programme d'écrire production["presse"] = -5.

Impératif / procédural
enregistrer()total()productionétat global, lisible et modifiable par tousun autre coderien ne l'en empêche

L'état est au centre, et chaque fonction du programme y est reliée : enregistrer() l'écrit, total() le lit. La flèche rouge est celle qui compte : elle ne part d'aucune fonction du programme, et rien ne l'interdit.

Objet : une cuve qui garde sa règle

Un centre d'usinage est alimenté en huile de coupe par une cuve de 1 000 litres. Le programme de supervision la remplit, et une règle ne doit jamais casser : la cuve ne déborde pas. Un état qui dure, et une règle qui doit tenir à chaque changement : c'est le terrain de l'objet, qui range dans un même objet les données et les opérations qui les concernent.

Rappel de cours : objet, méthode, encapsulation

Une classe décrit un type d'objet : les données qu'il porte, ses attributs, et les opérations qu'on peut lui demander, ses méthodes, écrites avec def à l'intérieur de la classe. Dans une méthode, self désigne l'objet dont on parle. raise ValueError(...) arrête la méthode avec une erreur, que l'appelant intercepte par try et except.

L'encapsulation consiste à ne toucher aux données d'un objet qu'à travers ses méthodes, pour qu'une règle vérifiée dans une méthode tienne toujours. En Python, c'est une convention : un nom qui commence par un souligné, comme _niveau, signale un attribut interne, mais rien n'empêche d'y écrire depuis l'extérieur.

Exemple résolu : un objet v dont la méthode regler refuse une valeur négative. v.regler(-3) lève l'erreur et laisse l'objet tel quel ; v._valeur = -3 passe sans que rien ne vérifie. Voir Classes et objets.

Prévoir avant d'exécuter

  • 1.

    Le programme crée une cuve de 1 000 litres, y verse 600 litres, puis tente d'en verser 500. Quel niveau affiche sa dernière ligne, en litres ?

main.py
Sortie
>_ Prêt à exécuter…

La même scène que tout à l'heure, avec une barrière en plus.

Orienté objet
Cuve_niveauétat interne, réservé aux méthodesremplir()niveau()exclu par conventionles méthodes sont le passage prévu

Les méthodes sont posées sur le bord de l'objet : elles sont le passage prévu vers l'état. La flèche barrée est celle du schéma précédent ; la convention l'exclut, Python ne l'interdit pas, comme le montre la suite.

Le gain est concret : la règle « pas de débordement » est écrite à un seul endroit, dans remplir, et tout remplissage passe par là. Le second remplissage est refusé avant que rien ne soit versé, et la cuve reste à 600 litres. La capacité appartient à la cuve, là où on la cherchera.

La garantie a une limite, qu'il faut connaître : cuve._niveau = 5000 s'exécute sans erreur, et la cuve affiche alors 5 000 litres pour une capacité de 1 000. Le souligné dit « ne pas toucher », il n'empêche pas de toucher. L'encapsulation protège ce qui passe par les méthodes ; pour fermer aussi cette porte, il faudrait des valeurs qu'on ne peut plus modifier, ce qui est précisément l'idée de la famille suivante.

Vérification rapideon peut se reprendre

1.Le programme ci-dessous enregistre trois lots, puis un quatrième à l'intérieur d'un print. Que fait apparaître la ligne print(enregistrer("presse", 15)) ?

2.Après les quatre lots, combien de pièces le compteur de la presse affiche-t-il ?

3.L'encapsulation sert d'abord à…

Pour aller plus loin (facultatif) : le diagramme de classes. La même structure se dessine en notation UML, celle des patrons de conception. Le signe qui précède chaque ligne dit ce qui est interne (-) et ce qui est offert à l'extérieur (+) ; une parenthèse signale une méthode.

Diagramme de classes
Cuve
- _niveau : float
+ capacite : float
+ remplir(litres)
+ niveau() : float

Fonctionnel : un journal dont chaque version reste

Au contrôle qualité, chaque pièce usinée est mesurée, et sa cote s'ajoute au journal du lot. Un client qui conteste une livraison demandera ce que contenait le journal au moment où le lot est parti : il faut donc que chaque version du journal reste consultable. C'est le terrain du fonctionnel : on ne modifie rien, chaque opération rend une valeur neuve, et les anciennes restent.

Rappel de cours : fonction pure, tuple prolongé

Une fonction est pure quand elle rend toujours le même résultat pour les mêmes arguments et qu'elle ne produit aucun effet de bord : elle ne modifie rien en dehors de ce qu'elle renvoie. len(l) est pure. l.append(x) ne l'est pas, puisqu'elle modifie la liste l. Une fonction qui lit l'horloge ne l'est pas non plus : appelée deux fois avec les mêmes arguments, elle peut rendre deux résultats.

Un tuple ne se modifie pas, mais on peut en fabriquer un plus long : t + (x,) rend un nouveau tuple, celui de t suivi de x, et laisse t intact. La virgule de (x,) est indispensable : c'est elle qui fait un tuple d'un seul élément.

Exemple résolu : avec t = (3, 4), l'instruction u = t + (5,) donne u == (3, 4, 5), et t vaut toujours (3, 4).

main.py
Sortie
>_ Prêt à exécuter…

Le résultat remarquable est la ligne j1 est intact : True. Trois cotes ont été ajoutées, et pourtant j1 contient encore la seule première, tandis que j3 en affiche trois. Chaque version du journal existe toujours. Retrouver l'état d'un lot à un instant donné ne demande aucun code : il suffit d'avoir gardé le nom de la version. Comparer deux états, rejouer un historique, annuler un ajout deviennent immédiats, et c'est exactement ce que demande le client qui conteste une livraison.

Fonctionnel
()ajoutertoujours làj1ajoutertoujours làj2ajoutertoujours làj3état courantaucune flèche ne revient en arrière : rien n'est modifié, tout est recrééannuler une opération, c'est reprendre l'état d'avant : il n'a jamais cessé d'exister

Aucune flèche ne revient en arrière, et aucune boîte n'est effacée. Les trois états précédents, le journal vide compris, sont dessinés pleins parce qu'ils existent encore : c'est ce que vérifie la ligne « j1 est intact ».

C'est aussi la version où le mot pure devient vérifiable. Contrairement à enregistrer dans la version procédurale, ajouter et moyenne renvoient tout ce qu'elles produisent. moyenne(j3) rend 25,01 mm, et l'appeler dix fois d'affilée rend dix fois la même valeur sans rien laisser derrière. Le résultat de la fonction est son seul effet.

Les mêmes gestes servent partout où l'on transforme des données sans rien garder : sorted(cotes) rend une liste triée neuve et laisse cotes telle quelle, quand cotes.sort() trie la liste elle-même ; une compréhension, [c for c in cotes if c > 25], fabrique elle aussi une liste neuve.

Pourquoi le fonctionnel convient au travail en parallèle. Un programme peut mener plusieurs traitements à la fois, les fils d'exécution, qui partagent les mêmes données. Une valeur qui ne change jamais peut être lue par mille fils en même temps sans le moindre verrou, ce mécanisme qui réserve une donnée à un seul fil à la fois. L'essentiel de la difficulté du parallélisme vient de l'état modifiable partagé : deux fils lisent la même valeur, la réécrivent chacun de leur côté, et l'une des deux écritures se perd. C'est la condition de course, dont la fin du chapitre montre un cas sur le compteur de production. Supprimer l'état modifiable partagé supprime cette classe de problèmes entière, et c'est la raison pour laquelle les langages fonctionnels ont regagné du terrain avec les processeurs multicœurs.

Le fonctionnel n'est pas gratuit

Rien ne se modifie, donc chaque ajout recopie tout. ajouter fabrique un tuple neuf, dans lequel elle recopie les cotes déjà là : l'ajout de la première cote n'en recopie aucune, celui de la deuxième en recopie 1, celui de la troisième 2, et ainsi de suite. Pour nn cotes ajoutées une à une, le total vaut 0+1+2+⋯+(n−1)=n(n−1)20 + 1 + 2 + \dots + (n-1) = \dfrac{n(n-1)}{2}, de l'ordre de n2/2n^2/2. Un lot de 400 pièces coûte ainsi 79 800 cotes recopiées pour n'en garder que 400. Une liste modifiable, avec journal.append(cote), ajoute une case au bout sans recopier le journal à chaque ajout.

Garder toutes les versions se paie aussi en mémoire : si chacune est conservée entière, les 400 versions successives totalisent 1+2+⋯+400=80 2001 + 2 + \dots + 400 = 80\,200 cases. Les langages fonctionnels compensent par des structures persistantes, qui partagent entre deux versions la part qui n'a pas changé. En Python, écrire naïvement du fonctionnel sur de gros volumes est réellement plus lent. Un argument « le fonctionnel est meilleur » qui ne cite pas ce coût est un argument incomplet.

Déclaratif : décrire le résultat, laisser le chemin au moteur

Le service maintenance consigne chaque arrêt de machine dans une base de données : la machine, la durée en minutes, la cause. Sa question du lundi : quels arrêts ont duré plus d'une demi-heure, du plus long au plus court, et combien de minutes au total ? C'est le terrain du déclaratif. On décrit le résultat voulu, comme dans la formule du tableur, et un moteur choisit comment l'obtenir. C'est exactement ce que fait SQL, et le bloc ci-dessous s'exécute vraiment.

Rappel de cours : une table, une contrainte, une requête

Une table SQL range des lignes qui ont toutes les mêmes colonnes. CHECK (duree_min >= 0) ajoute une contrainte à une colonne : toute ligne qui la viole est refusée par la base, avec une erreur, et n'est pas enregistrée. Une requête SELECT dit quelles lignes on veut (WHERE), dans quel ordre (ORDER BY, suivi de DESC pour l'ordre décroissant), ou quel résumé (SUM additionne une colonne), sans dire comment les trouver.

Exemple résolu : dans une table de trois arrêts de 10, 45 et 70 minutes, SELECT SUM(duree_min) FROM arret WHERE duree_min > 30 rend 115. Une insertion d'une durée de −5-5 minutes serait refusée. Voir Les requêtes SQL.

Prévoir avant d'exécuter

  • 1.

    Combien de lignes la première requête SELECT rend-elle ?

  • 2.

    Quel total, en minutes, rend la seconde requête ?

requete.sql
Résultat
>_ Prêt à exécuter…

Nulle part il n'est écrit comment trouver les arrêts de plus de 30 minutes, ni comment les trier : pas de boucle, pas de comparaison écrite à la main. La contrainte duree_min >= 0 est déclarée une fois, et la base la fait respecter à chaque insertion, d'où qu'elle vienne. C'est le pendant de la règle écrite dans la méthode remplir de la cuve, à une différence près : ici, aucune ligne de code applicatif ne la vérifie, et le programme qui insère ne peut pas l'oublier.

Déclaratif
ce qu'on écritle quoiWHERE duree_min > 30moteur SQLle commentparcourir ou lire un index ?filtrer avant de trier ?quel algorithme de tri ?ce qu'on obtientles arrêts voulus, triésces décisions sont prises à chaque exécution, et aucune ligne du programme ne les écrit

Le programme écrit la boîte de gauche et obtient celle de droite ; celle du milieu, il ne l'écrit pas. Les décisions en pointillés sont prises par le moteur à chaque exécution, et c'est ce qui rend le déclaratif si court à écrire et si opaque quand il faut comprendre une lenteur.

D'autres formes de déclaratif
  • La formule d'un tableur, celle de l'ouverture : elle dit ce que vaut une case, et le tableur décide quand la recalculer.
  • Une expression régulière décrit la forme d'un texte, pas l'automate qui le reconnaît.
  • Le HTML décrit la structure d'une page, pas les instructions qui la dessinent.
  • Une liste en compréhension Python, [a["machine"] for a in arrets if a["duree_min"] > 30], penche du même côté : elle décrit l'ensemble voulu, pas la boucle.
Vérification rapideon peut se reprendre

1.Le programme part d'un journal vide et y ajoute trois cotes, chaque fois dans un nouveau journal. Que montre la ligne « j1 est intact » ?

2.Dans la version SQL, qui vérifie qu'une durée d'arrêt n'est jamais négative ?

Ce que change l'état modifiable

Les quatre familles ne se séparent pas d'abord par leur syntaxe, mais par ce qu'elles font de l'état, et le clivage se voit mieux sur un défaut que sur un discours. Avant de modifier les réglages d'une presse, le programme de supervision en garde une sauvegarde, pour pouvoir revenir en arrière si le nouveau réglage se passe mal.

Rappel de cours : affecter, copier, copier en profondeur

Une variable est une étiquette collée sur un objet, pas une boîte qui le contient. L'affectation b = a ne copie rien : elle colle une seconde étiquette sur le même objet.

a.copy() fabrique une copie superficielle : une nouvelle liste, dont les cases désignent les mêmes objets que l'ancienne. copy.deepcopy(a), après import copy, fabrique une copie profonde : une nouvelle liste et de nouveaux objets à l'intérieur, jusqu'au fond.

Exemple résolu : avec a = [[1]], puis b = a.copy() et b[0][0] = 9, a[0][0] vaut 9, parce que la liste intérieure est partagée ; avec b = copy.deepcopy(a), a[0][0] serait resté à 1. Le chapitre Listes, dictionnaires et tuples le montre dans ses sections sur les deux étiquettes et sur les listes de listes.

Prévoir avant d'exécuter

  • 1.

    reglages est une liste qui contient un seul dictionnaire, {"machine": "presse", "pression": 14}. Après sauvegarde = reglages, puis reglages[0]["pression"] = 140, que vaut sauvegarde[0]["pression"] ?

  • 2.

    Même scène, mais la sauvegarde est faite par sauvegarde = copy.deepcopy(reglages). Que vaut alors sauvegarde[0]["pression"] ?

  • 3.

    Même scène, avec sauvegarde = reglages.copy(). Que vaut sauvegarde[0]["pression"] ?

Le programme ci-dessous joue la première scène, puis la même modification sur une structure immuable.

main.py
Sortie
>_ Prêt à exécuter…

Les deux moitiés du programme se lisent dans ce qu'il affiche. En haut, une seule ligne a été modifiée, reglages[0]["pression"] = 140, et pourtant sauvegarde affiche 140 elle aussi : la sauvegarde, faite justement pour réparer un mauvais réglage, a reçu l'erreur. En bas, la même modification sur le tuple ne s'exécute pas : Python répond 'tuple' object does not support item assignment. Ce n'est pas une convention à respecter, c'est un refus. Pour changer le réglage, il faudrait fabriquer un tuple neuf, et la sauvegarde garderait l'ancien.

L'affectation ne copie pas

sauvegarde = reglages ne duplique rien : les deux noms désignent le même objet en mémoire. Le défaut est silencieux, il se manifeste loin de sa cause, et c'est l'un des plus coûteux à diagnostiquer, en Python comme dans la plupart des langages.

Le style fonctionnel ne le corrige pas : il le rend impossible à écrire. C'est une différence de nature entre « faire attention » et « ne pas pouvoir se tromper ».

La cause tient dans une image mentale fausse. On se représente une variable comme une boîte qui contient une valeur, alors qu'elle est une étiquette collée sur un objet. L'affectation ne déplace pas de contenu : elle colle une seconde étiquette. Sur les figures qui suivent, les noms sont à gauche et les objets à droite. Un cadre en trait plein est un objet modifiable, un cadre en pointillé un objet immuable ; un cadre rouge signale un objet à la fois modifiable et désigné par plusieurs noms, le cas où modifier par un nom modifie pour tous.

les nomsles objets en mémoirereglagessauvegarde[{"machine": "presse", "pression": 14}]modifiable
Après « sauvegarde = reglages ». Un seul objet, deux étiquettes, et un cadre rouge. Modifier par l'une modifie pour l'autre, puisqu'il n'y a rien d'autre à modifier.

Sur cette figure, compter les cadres : il n'y en a qu'un, pour deux noms. C'est là tout le problème, et les figures suivantes se lisent en comptant de la même façon.

Deux façons de s'en sortir, et elles ne se valent pas. La première consiste à copier vraiment : sauvegarde = copy.deepcopy(reglages), après un import copy. Elle donne deux objets distincts, mais demande de penser à le faire à chaque fois.

les nomsles objets en mémoirereglagessauvegarde[{"machine": "presse", "pression": 140}]modifiable[{"machine": "presse", "pression": 14}]modifiable
Après une copie en profondeur, puis la faute de frappe. Deux objets, deux étiquettes, aucune interférence : mais rien dans le langage ne rappelle de faire cette copie.

Deux cadres cette fois, et deux pressions différentes : 140 d'un côté, 14 de l'autre. C'est bien ce qu'on voulait, puisque la sauvegarde permet de réparer la faute de frappe, au prix d'une ligne que rien n'oblige à écrire.

La copie la plus évidente, reglages.copy(), ne suffit pas ici : c'est une copie superficielle, une nouvelle liste qui contient le même dictionnaire. La figure suivante dessine la case 0 de chaque liste comme un nom de plus.

les nomsles objets en mémoirereglagessauvegardereglages[0]sauvegarde[0][ case 0 ]modifiable[ case 0 ]modifiable{"machine": "presse", "pression": 140}modifiable
Après « sauvegarde = reglages.copy() », puis la faute de frappe. Deux listes, mais un seul dictionnaire, désigné par la case 0 de chacune : c'est lui que la faute de frappe a changé, et son cadre est rouge.

Trois cadres, dont un rouge. Les deux listes sont distinctes, et ajouter un réglage à l'une ne toucherait pas l'autre ; mais leur contenu est commun. C'est ce qui rend la copie superficielle si traîtresse : elle protège la structure de la liste, pas ce qu'elle contient.

La seconde façon consiste à rendre l'objet immuable. Le partage subsiste, et il devient inoffensif : on ne peut modifier ce qui ne se modifie pas, donc la question ne se pose plus.

les nomsles objets en mémoirereglages_figessauvegarde_2(("presse", 14),)immuable
Le même partage, sur un objet immuable. Aucun cadre rouge : le partage n'est dangereux que s'il s'accompagne de mutabilité.

Ce que ces figures disent ensemble. Le défaut ne vient pas du partage, et il ne vient pas de la mutabilité : il vient de leur rencontre. Supprimer l'un des deux suffit, et l'immuabilité est celui qu'un langage peut garantir à la place du programmeur. C'est tout l'argument du style fonctionnel, et c'est aussi la raison pour laquelle ses structures se partagent entre fils d'exécution sans verrou : ce qui ne change pas ne peut pas être vu à moitié changé.

Deux écritures en même temps

Le partage a une forme que la copie ne règle pas : deux utilisateurs qui modifient la même donnée au même moment. Le compteur de la presse, celui de la version procédurale, est maintenant tenu par une base que deux terminaux d'atelier mettent à jour. Il vaut 180 pièces. Le terminal A enregistre un lot de 12 pièces, le terminal B un lot de 20, à quelques millisecondes d'écart.

Le scénario tient en six échanges, et rien n'y est anormal pris isolément. Le diagramme se lit de haut en bas, dans l'ordre du temps : une flèche pleine est une demande, une flèche en pointillé une réponse. Regarder les deux premières réponses du compteur : elles donnent la même valeur, 180, à deux interlocuteurs différents. Tout le défaut est déjà là.

Diagramme de séquence
Diagramme de séquence entre Terminal A, Compteur, Terminal B, en 6 messages :
  1. Terminal A vers Compteur : lire le compteur.
  2. Compteur vers Terminal A, en réponse : 180.
  3. Terminal B vers Compteur : lire le compteur.
  4. Compteur vers Terminal B, en réponse : 180.
  5. Terminal A vers Compteur : écrire 180 + 12 = 192.
  6. Terminal B vers Compteur : écrire 180 + 20 = 200. Note : le lot de A vient d'être écrasé.

Deux lots ont été enregistrés, un seul subsiste : le compteur affiche 200 pièces au lieu de 212, et les 12 pièces de A ne figurent plus nulle part. Aucune exception n'est levée, aucun test ne le voit, et le défaut n'apparaît que sous charge, à la relève, quand tous les postes déclarent leur production en même temps.

Ce défaut a un nom, la perte de mise à jour. Le remède est un contrôle au moment d'écrire : vérifier, quand B veut écrire 200, que le compteur vaut toujours 180. S'il vaut 192, l'écriture est refusée ; B relit 192 et recalcule 212. Ce contrôle ne dépend d'aucune famille, et un programme procédural peut le faire aussi bien qu'un autre. Le style fonctionnel ne suffit pas non plus, à lui seul, à éviter le défaut : deux fonctions pures parties du même 180 calculeraient encore 192 et 200. Ce qu'il apporte, c'est que le calcul ne touche à rien : il rend un nouvel état, et l'état dont il est parti reste connu, ce qui est exactement ce que le contrôle doit comparer. Ce contrôle est le métier d'une base de données, celui des transactions.

Vérification rapideon peut se reprendre

1.Le principal risque de l'état modifiable partagé, c'est…

2.Pourquoi une structure immuable simplifie-t-elle le raisonnement sur un programme ?

3.Une fonction pure se reconnaît à quoi ?

4.Pourquoi l'état modifiable partagé pose-t-il problème dès que plusieurs traitements s'exécutent en même temps ?

Choisir une famille

La bonne question n'est jamais « quel est le meilleur paradigme », mais « lequel pour ce problème, sous ces contraintes ». La méthode tient en trois questions, qui reprennent la carte.

  1. Le programme a-t-il un état qui dure et qui change ? Un script qui transforme des données sans rien garder n'en a pas : fonctionnel, ou une simple suite d'étapes. Une simulation, une machine pilotée, une session d'utilisateur en ont un : objet.
  2. Cet état est-il partagé, entre plusieurs parties du programme ou plusieurs utilisateurs ? Si oui, l'immuabilité cesse d'être une élégance pour devenir une garantie, et l'écriture partagée demande un contrôle, celui d'une base de données.
  3. Le domaine a-t-il déjà un langage déclaratif ? Calcul entre des cases, requête sur des données, validation d'un format : inutile de réécrire en étapes ce qu'un tableur, SQL ou une expression régulière fait mieux.
Vérification rapideon peut se reprendre

1.Chaque soir, un script lit les relevés bruts d'un banc d'essai moteur, les convertit en unités physiques et écrit un rapport. Il ne garde rien d'une exécution à l'autre. Quelle famille lui convient ?

2.Une simulation suit 40 machines d'atelier, dont chacune change d'état à chaque pas de temps : en marche, en panne, en maintenance. Quelle famille lui convient ?

3.Il faut retrouver, parmi 200 000 interventions de maintenance consignées en base, celles qui ont duré plus de 4 heures. Quelle famille lui convient ?

4.Un même programme peut s'écrire dans plusieurs familles. Laquelle choisir ?

La cellule et la variable, pour finir

La question d'ouverture opposait une case de tableur qui suit ses données à une variable Python qui ne les suit pas. La différence ne vient ni de Python ni du tableur, elle vient de la famille. La formule du tableur est déclarative : elle énonce une relation, que le moteur maintient vraie. L'affectation Python est impérative : une étape faite une fois, dont le résultat ne sait plus d'où il vient. Aucune des deux n'a tort. La case convient à une feuille qu'on fait varier pour voir, le script à une suite de traitements qu'on veut maîtriser pas à pas.

Ce qu'il faut savoir d'un programme avant de lui confier des données que d'autres modifient tient en trois points.

  • Où vit son état : dans une variable ouverte à tous, dans un objet derrière ses méthodes, dans des valeurs qui ne changent plus, ou dans un moteur.
  • Si cet état est à la fois partagé et modifiable : c'est leur rencontre, et non l'un ou l'autre, qui produit les défauts, de la sauvegarde qui bouge avec l'original au lot de pièces écrasé.
  • Ce qui protège l'écriture, et à quel prix : une copie, une valeur immuable ou un contrôle au moment d'écrire, payés en recopies, en mémoire ou en vérifications.

La méthode

  1. Nommer la famille avant de décrire une solution : « une requête », « une classe », « des fonctions pures » situent tout ce qui suit.
  2. Dire où vit l'état : une variable ouverte à tous, un objet derrière ses méthodes, des valeurs successives, un moteur. C'est la question qui distingue les familles.
  3. Prévoir avant d'exécuter : pour chaque affectation, copie ou appel, compter les objets qui existent et dire lequel change.
  4. Chercher le partage : deux noms sur un même objet, une fonction qui modifie la liste reçue, deux utilisateurs qui écrivent la même donnée.
  5. Couper la rencontre du partage et de la mutation : une copie profonde, une valeur immuable, une fonction qui rend du neuf, ou un contrôle au moment d'écrire.
  6. Citer le coût de la parade : des recopies, des versions à garder, des vérifications. Une solution dont on ne dit pas l'inconvénient n'a pas été examinée.

Mettre en pratique

Le débat sur l'état modifiable se tranche aussi par un comptage, celui des cas qu'il faut essayer pour éprouver une fonction. C'est l'argument le plus concret en faveur des fonctions sans effet de bord.

Rappel de cours : compter les cas

Un booléen prend deux valeurs, vrai ou faux. Chaque booléen de plus double le nombre de combinaisons, puisque chacune des précédentes se décline une fois avec lui vrai et une fois avec lui faux : nn booléens donnent 2n2^n cas. Deux groupes indépendants de cas se combinent en multipliant leurs comptes.

Exemple résolu : quatre interrupteurs ont 2×2×2×2=24=162 \times 2 \times 2 \times 2 = 2^4 = 16 positions ; avec en plus un sélecteur à trois positions, cela fait 16×3=4816 \times 3 = 48 réglages.

Ce que l'état coûte en vérifications

  • 1.

    Une fonction pure reçoit 3 paramètres booléens et ne dépend de rien d'autre. Combien de cas suffisent à l'éprouver entièrement ?

  • 2.

    La même fonction lit en plus un état caché formé de 2 booléens. Combien de cas faut-il alors éprouver ?

  • 3.

    Par quel facteur cet état multiplie-t-il le travail de vérification ?

  • 4.

    Un objet porte un état caché de 10 booléens. Par quel facteur multiplie-t-il ce travail ?

La dernière réponse ne dit pas qu'une fonction pure a moins de cas : si elle avait besoin des mêmes dix informations, elle les recevrait en paramètres, et le compte serait le même. Elle dit où ces cas se trouvent. En paramètres, ils sont écrits dans la signature, et chacun s'essaie en une ligne de test. Cachés dans l'objet, ils ne se voient pas à l'appel : il faut d'abord amener l'objet dans chacun de ses 210=10242^{10} = 1024 états par une suite d'appels avant de vérifier quoi que ce soit. Personne ne le fait, ce qui veut dire que personne ne sait vraiment ce que cet objet fait dans la plupart de ses états.

Les deux parades de la fin du chapitre ont aussi un prix qui se compte : les recopies du journal fonctionnel, et les pièces perdues quand plusieurs écritures partent de la même lecture.

Rappel de cours : la somme des premiers entiers

0+1+2+⋯+(n−1)=n(n−1)20 + 1 + 2 + \dots + (n-1) = \dfrac{n(n-1)}{2}. On le retrouve en écrivant la somme deux fois, une fois à l'endroit et une fois à l'envers, l'une sous l'autre : chacune des nn colonnes totalise n−1n - 1, et les deux sommes réunies valent donc n(n−1)n(n-1).

Exemple résolu : 0+1+2+3+4=5×42=100 + 1 + 2 + 3 + 4 = \dfrac{5 \times 4}{2} = 10, ce que l'addition directe confirme.

Ce que coûtent les copies et le partage

  • 1.

    Le journal fonctionnel d'un lot de 600 pièces est construit cote par cote, chaque ajout recopiant tout le tuple précédent. Combien de cotes sont recopiées en tout ?

  • 2.

    Le compteur de la presse vaut 180 pièces. Trois terminaux le lisent tous avant qu'aucun n'écrive, puis écrivent dans l'ordre A, B, C ce qu'ils ont lu augmenté de leur lot, de 12, 20 et 8 pièces. Que vaut le compteur à la fin ?

  • 3.

    Combien de pièces ce compteur a-t-il perdues ?

Ce que « sans effet de bord » achète réellement. Une fonction dont le résultat ne dépend que de ses arguments se raisonne seule, se teste seule, et se remplace par son résultat partout où elle apparaît. Les trois propriétés viennent du même fait : il n'existe aucun contexte à connaître pour prédire ce qu'elle rend.

Ce n'est pas une raison de bannir l'état, qui est parfois exactement ce qu'il faut représenter, comme le niveau d'une cuve. C'est une raison de savoir où il se trouve, et de le tenir à un seul endroit plutôt que dispersé.

Les notions employées ont toutes été rappelées plus haut : l'effet de bord avec la version procédurale, l'encapsulation avec la version objet, la fonction pure avec la version fonctionnelle, le déclaratif avec le tableur et SQL. Répondre par écrit, en quelques phrases, avant d'ouvrir la réponse.

La moyenne des cotes d'un journal, écrite avec une boucle qui accumule, puis avec sum. Qu'est-ce qui change vraiment ?

Avec la boucle : total = 0, puis for c in journal: total += c, et enfin total / len(journal). On décrit comment accumuler, en modifiant total à chaque tour.

Avec sum : sum(journal) / len(journal). On décrit ce qu'est la moyenne : la somme des cotes divisée par leur nombre.

Ce qui change réellement : dans la première version, total est une variable du programme qui passe par des valeurs partielles, et un return mal placé ou une boucle interrompue en renverrait une. Dans la seconde, les sommes partielles existent aussi, mais à l'intérieur de sum, hors de portée : l'expression ne peut rendre qu'une valeur, la bonne.

Sur trois lignes c'est anecdotique. Sur un calcul de cent lignes avec dix variables intermédiaires, c'est la différence entre un code qu'on peut raisonner et un code qu'on doit exécuter de tête.

Pourquoi dit-on que les fonctions pures sont plus faciles à tester ?

Une fonction pure ne dépend que de ses arguments et ne modifie rien d'extérieur. Son test s'écrit donc en une ligne, avec les données sous les yeux : assert ajouter((25.02,), 24.98) == (25.02, 24.98).

Une fonction à effet de bord suppose un état préalable : pour tester enregistrer, il faut d'abord mettre production dans un état connu, appeler la fonction, puis vérifier après coup que le dictionnaire a été correctement modifié. Le test devient un scénario, sensible à l'ordre dans lequel les autres tests ont touché au même dictionnaire.

C'est aussi ce qui permet de garder en mémoire le résultat d'une fonction pure pour le resservir sans recalcul : la même entrée donnera toujours la même sortie.

Un tableur est-il un programme ? Et de quelle famille relève-t-il ?

Oui : une feuille qui calcule est un programme, dont les instructions sont les formules. Il relève du déclaratif : chaque formule énonce ce que vaut une case en fonction d'autres cases, et c'est le tableur qui décide dans quel ordre recalculer, à chaque changement. On n'y écrit jamais « faire ceci, puis cela ».

C'est pour cela qu'il se met à jour seul, comme dans l'ouverture du chapitre. C'est aussi sa limite : une suite de traitements dont l'ordre compte, lire un fichier, le nettoyer, l'envoyer, s'y écrit mal, et les tableurs ajoutent pour cela des macros, qui sont des programmes impératifs.

Dans la version objet, pourquoi _niveau porte-t-il un souligné ?

C'est la convention Python pour signaler un attribut interne : il n'appartient pas à l'interface publique de la cuve, et le code extérieur à la classe ne doit pas y accéder.

Python ne l'empêche pas techniquement : cuve._niveau reste lisible et modifiable. Contrairement à Java ou à C++, où le compilateur refuse l'accès à un attribut déclaré privé, l'encapsulation y est une convention entre développeurs, pas une contrainte du langage.

Ce qu'elle protège n'est pas la donnée mais la liberté de la changer : tant que personne n'y accède directement, remplacer l'attribut par la lecture d'un capteur de niveau ne casse aucun code appelant, puisque tous passent par niveau().

Un traitement doit s'exécuter sur 16 cœurs. Quelle famille, et pourquoi ?

Fonctionnelle, et la raison est précise : des données immuables se partagent entre cœurs sans verrou. Aucun cœur ne peut modifier ce qu'un autre est en train de lire, donc, pendant le calcul, ni condition de course ni interblocage.

Reste un point de rencontre, la mise en commun des résultats, qu'il faut organiser : chaque cœur rend son résultat partiel, et une étape les combine, par exemple en les additionnant. En procédural ou en objet avec un état modifiable partagé, il faudrait au contraire protéger chaque accès par un verrou : code plus complexe, plus lent, et sujet à des défauts qui ne se reproduisent pas.

Deux nuances honnêtes. En Python, seize cœurs se mobilisent en général par seize processus, qui travaillent chacun sur une copie des données : le partage disparaît, mais les copies se paient. Et le traitement doit se décomposer en parties indépendantes : un calcul où chaque étape a besoin du résultat de la précédente ne se parallélise pas mieux en fonctionnel qu'ailleurs.

Pourquoi ne pas tout écrire en déclaratif, puisque c'est le plus concis ?

Parce que le déclaratif n'existe que dans un domaine où un moteur sait résoudre le problème. SQL sait interroger des tables, un tableur sait recalculer des cases, une expression régulière sait reconnaître un motif. Hors de leur domaine, il n'y a pas de moteur.

Deux limites supplémentaires. Le débogage : quand une requête SQL est lente, on ne lit pas le code du moteur, on interroge un plan d'exécution, et l'abstraction devient opaque au pire moment. Et l'expressivité : dès que la logique métier a des cas particuliers, la traduire en règles déclaratives produit quelque chose de moins lisible que dix lignes impératives.

Le bon usage est celui d'un outil spécialisé : déclaratif là où le domaine s'y prête, impératif ou fonctionnel pour le reste.

Chapitre révisé en octobre 2026 · Une erreur dans ce chapitre ?

Dans la bibliothèque d'exercices