Aller au contenu principal

Paradigmes de programmation

Ce que ce chapitre apporte

  • Définir ce qu'est un paradigme de programmation et le distinguer d'un langage.
  • Nommer et caractériser les quatre paradigmes principaux.
  • Implémenter un même problème en procédural, en orienté objet et en fonctionnel.
  • Reconnaître le rôle de l'état mutable dans les défauts d'un programme.
  • Comparer les approches sur des critères explicites et justifier une recommandation.

Deux programmes qui rendent exactement le même service peuvent être structurés de façons irréconciliables. Ce n'est pas une question de langage ni de style : c'est une question de paradigme, c'est-à-dire de la manière dont on se représente ce qu'est un programme. Ce chapitre prend un seul problème (un gestionnaire de stock) et l'écrit quatre fois. L'objectif n'est pas de désigner un vainqueur, c'est de savoir justifier un choix devant une contrainte donnée.

Il ouvre le deuxième temps du parcours : après les cinq chapitres consacrés à décider et mesurer sous incertitude, on passe à l'écriture du programme lui-même, et à ce qu'elle coûte.

Un paradigme n'est pas un langage

Définition

Un paradigme de programmation est une façon de concevoir et de structurer un programme : ce qu'on considère comme l'unité de base, comment on la compose, et où vit l'information.

Trois confusions à écarter d'emblée.

Un paradigme n'est pas un langage. Python permet d'écrire du procédural, de l'objet et du fonctionnel dans le même fichier. Java, longtemps purement objet, a intégré des fonctions de première classe. L'expression désigne des fonctions que le langage traite comme n'importe quelle autre valeur : on peut les ranger dans une variable, les passer en argument, et en renvoyer une comme résultat. C'est la condition minimale pour écrire du fonctionnel.

L'usage le plus courant en est sorted(capteurs, key=lambda c: c.derniere_mesure) : la fonction de tri reçoit en paramètre une autre fonction, qui dit selon quoi trier. Le parcours Python pratique ce geste dans son chapitre sur les fonctions avancées. Les langages sont majoritairement multi-paradigmes ; ce sont les programmeurs qui choisissent.

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

Ce n'est pas non plus une religion. Le vrai code mélange : un service web déclare ses routes de façon déclarative, traite ses données de façon fonctionnelle et gère ses connexions par des objets.

La carte des quatre paradigmes

ParadigmeQuestion centraleUnité de baseLangages typiques
Impératif / procéduralComment faire ?l'instruction, la procédureC, Python, Pascal
Orienté objetQui fait quoi ?la classe, l'objetJava, C++, Python
FonctionnelQu'est-ce que c'est ?la fonction, la valeurHaskell, OCaml, Lisp
DéclaratifQuoi obtenir ?la règle, la requêteSQL, Prolog, HTML
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 fonctionnel penche vers le déclaratif, l'objet reste fondamentalement impératif. La question à retenir : le code écrit-il comment, ou quoi ?

Le même problème, quatre fois

Le cahier des charges, identique pour les quatre versions : gérer une liste de produits, chacun ayant un nom, une quantité en stock et un prix unitaire. Le système doit permettre d'ajouter un produit, de calculer la valeur totale du stock et de rechercher un produit par son nom.

Version impérative / procédurale

On décrit la suite des opérations, comme dans les algorithmes en pseudo-code du parcours d'algorithmique. Les données sont dans une structure globale, les procédures la modifient.

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

Direct, lisible, minimal. Et une faiblesse structurelle : stock est accessible et modifiable de partout. Rien n'empêche un autre bout de programme d'écrire stock[0]["quantite"] = -5.

Un détail du programme mérite d'être regardé de près, parce que tout le chapitre tourne autour. L'appel ajouter("clavier", 3, 45.0) renvoie None, c'est-à-dire rien du tout ; et pourtant la ligne suivante affiche un clavier à 15 exemplaires au lieu de 12. Ce que la fonction rend et ce qu'elle fait sont deux choses distinctes, et c'est le second qui compte : le nom de cet écart est l'effet de bord. Une fonction à effet de bord ne se lit pas dans son appel, seulement dans son corps.

Version orientée objet

On regroupe les données et les opérations qui les concernent. L'état devient interne, protégé derrière une interface.

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

La structure que le code installe se lit mieux dessinée. Sur le schéma ci-dessous, regarder deux choses. D'abord le signe qui précède chaque ligne : un - pour ce qui est interne, un + pour ce qui est offert à l'extérieur. Ensuite la flèche du bas, qui dit comment les deux classes sont liées.

Diagramme de classes
Stock
- _produits : dict
+ CAPACITE = 50
+ ajouter(nom, quantite, prix)
+ valeur_totale() : float
+ rechercher(nom) : Produit
Produit
- nom : str
- quantite : int
- prix : float
+ valeur() : float

Le losange plein dit composition : un produit n'a pas d'existence hors de son stock, il disparaît avec lui. C'est bien ce que fait le code, puisque les produits ne vivent que dans le dictionnaire privé.

Deux gains concrets. La contrainte « quantité positive » est vérifiée à un seul endroit, le constructeur de Produit, par lequel passent toutes les méthodes du Stock. Les deux dernières lignes du programme le montrent. Créer une souris à 4-4 et retirer cent claviers d'un stock qui en compte quinze déclenchent la même exception, alors que ce sont deux erreurs de nature différente. Et la limite de 50 références appartient au Stock, là où on la cherchera.

La garantie a une limite, qu'il faut connaître : rechercher renvoie le produit lui-même, et un appelant peut encore écrire stock.rechercher("clavier").quantite = -5. L'encapsulation protège ce qui passe par les méthodes ; pour fermer aussi cette porte, il faudrait renvoyer une copie ou rendre Produit immuable, ce qui est précisément l'idée de la version suivante.

Le dictionnaire n'est pas un détail d'implémentation

La version procédurale parcourt la liste jusqu'à trouver le nom cherché. La version objet le donne directement au dictionnaire. L'écart se compte en comparaisons de noms : chercher une référence absente parmi 50 000 en demande 50 000 à la première, une seule à la seconde. C'est le passage de O(n)O(n) à O(1)O(1), d'un temps proportionnel au nombre de produits à un temps qui n'en dépend pas.

Ce changement est invisible de l'extérieur : rechercher s'appelle de la même façon. C'est précisément l'intérêt de l'encapsulation. Sur 50 références l'écart ne se mesure pas ; sur 50 000, il décide de tout. C'est le sujet du chapitre suivant.

Version fonctionnelle

On bannit la modification. Chaque opération renvoie un nouvel état au lieu de changer l'ancien, et les fonctions sont pures : même entrée, même sortie, aucun effet de bord.

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

Le résultat remarquable est la ligne s1 est intact : True. Trois ajouts ont eu lieu, et pourtant s1 contient encore le clavier à 12 exemplaires du premier appel, tandis que s3 en affiche 15. Chaque version du stock existe toujours. Annuler une opération ne demande aucun code : il suffit de reprendre l'état précédent. Rejouer un historique, comparer deux états, auditer une modification deviennent triviaux.

C'est aussi la version où le mot pure devient vérifiable. Contrairement à ajouter de la version procédurale, chacune de ces trois fonctions renvoie tout ce qu'elle produit. valeur_totale(s3) rend 1624.5, et l'appeler dix fois d'affilée rend dix fois 1624.5 sans rien laisser derrière. Le résultat de la fonction est son seul effet.

Pourquoi le fonctionnel est le paradigme de la concurrence

Une valeur qui ne change jamais peut être lue par mille fils d'exécution simultanément sans le moindre verrou, ce mécanisme qui réserve une donnée à un seul lecteur à la fois. L'essentiel de la difficulté du parallélisme (accès concurrents, conditions de course, blocages) vient de l'état mutable partagé. Le supprimer supprime cette classe de problèmes entière, et c'est la raison pour laquelle les paradigmes fonctionnels ont resurgi avec les processeurs multi-cœurs.

Version déclarative

On ne décrit ni les étapes, ni les objets : on décrit le résultat voulu. Un moteur choisit comment l'obtenir. C'est exactement ce que fait SQL, et ce bloc s'exécute vraiment.

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

Nulle part il n'est écrit comment trier, ni dans quel ordre parcourir la table. La contrainte quantite >= 0 est déclarée une fois, et le moteur la fait respecter. C'est le pendant de la validation écrite dans le constructeur de la version objet, à une différence près : ici, aucune ligne de code applicatif ne la vérifie.

D'autres formes de déclaratif
  • Une expression régulière décrit la forme d'un texte, pas l'automate qui le reconnaît.
  • Le HTML décrit une structure, pas les instructions de rendu.
  • Prolog énonce des faits et des règles, et le moteur cherche les solutions.
  • Une liste en compréhension Python, [p.nom for p in stock if p.quantite < 5], penche du même côté : elle décrit l'ensemble voulu, pas la boucle.
Vérification rapideon peut se reprendre

1.Un paradigme de programmation, c'est…

2.Le principal risque de l'état mutable partagé, c'est…

3.L'encapsulation sert d'abord à…

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

5.Un même problème s'écrit dans quatre paradigmes. Lequel choisir ?

Ce que change l'état mutable

C'est le vrai clivage entre les paradigmes, et il se voit mieux sur un bug que sur un discours.

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, stock_initial[0]["quantite"] = 999, et pourtant sauvegarde affiche 999 elle aussi. En bas, la tentative équivalente 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.

L'affectation ne copie pas

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

Le paradigme 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.

les nomsles objets en mémoirestock_initialsauvegarde[{"nom": "clavier", "quantite": 12}]modifiable
Après « sauvegarde = stock_initial ». Un seul objet, deux étiquettes. Modifier par l'une modifie pour l'autre, puisqu'il n'y a rien d'autre à modifier.

Sur la figure ci-dessus, compter les cadres : il n'y en a qu'un, pour deux noms. C'est là tout le problème, et les deux 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. Elle donne deux objets distincts, mais demande de penser à le faire à chaque fois, et une copie de surface ne suffit pas dès qu'il y a des dictionnaires imbriqués.

les nomsles objets en mémoirestock_initialsauvegarde[{"nom": "clavier", "quantite": 999}]modifiable[{"nom": "clavier", "quantite": 12}]modifiable
Après une copie en profondeur. Deux objets, deux étiquettes, aucune interférence : mais rien dans le langage ne rappelle de faire cette copie.

Deux cadres cette fois, et deux quantités différentes : 999 d'un côté, 12 de l'autre. C'est bien ce qu'on voulait, au prix d'une ligne que rien n'oblige à écrire.

La seconde 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émoirestock_initialsauvegarde("clavier", 12)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 trois figures disent ensemble

Le bug 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 paradigme fonctionnel, et c'est aussi la raison pour laquelle ses structures se parallélisent sans verrou : ce qui ne change pas ne peut pas être vu à moitié changé.

Comparer sur des critères

Les quatre versions sont écrites et rendent le même service. La comparaison peut donc se faire ligne à ligne, sur des critères nommés, plutôt qu'en préférence.

CritèreProcéduralObjetFonctionnelDéclaratif
État mutableglobal, partoutencapsulé dans l'objetabsentgéré par le moteur
Testabilitémoyenne (état global)bonne (objet isolable)excellente (fonctions pures)bonne (requête vérifiable)
Concurrencedifficile (verrous)difficile (verrous)naturelledéléguée au moteur
Lisibilité à petite échelleexcellentelourdedéroutante au débutexcellente
Passage à l'échellemauvaisbonbonexcellent dans son domaine
Coût mémoirefaiblemoyenplus élevé (copies)opaque
Débogagefacile (pas à pas)moyenfacile (pas d'effet caché)difficile (boîte noire)
Le fonctionnel n'est pas gratuit

Recréer une structure à chaque modification coûte de la mémoire et des copies. Dans la version fonctionnelle, ajouter recopie tout le tuple : le premier ajout recopie 0 produit, le deuxième 1, le troisième 2, et ainsi de suite. Le total de nn ajouts successifs vaut donc n(n1)/2n(n-1)/2, de l'ordre de n2/2n^2/2. Pour mille produits rangés un par un, cela fait 499 500 produits recopiés pour n'en garder que mille.

Les langages fonctionnels compensent par des structures persistantes qui partagent la part inchangée. En Python, écrire naïvement du fonctionnel sur de gros volumes est réellement plus lent. Un argument « le fonctionnel est meilleur » sans mention de ce coût est un argument incomplet.

Vérification

Vérification rapideon peut se reprendre

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

2.Pourquoi l'état mutable partagé pose-t-il problème dès qu'il y a plusieurs fils d'exécution ?

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

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

Choisir, et le justifier

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.

  1. Le programme a-t-il un état qui évolue ? Un script de transformation de fichier n'en a pas : fonctionnel ou déclaratif. Une simulation, un jeu, une session utilisateur en ont un : objet.
  2. Cet état est-il partagé entre plusieurs fils d'exécution ? Si oui, l'immuabilité cesse d'être une élégance pour devenir une garantie de correction.
  3. Le domaine a-t-il déjà un langage déclaratif ? Requêtes de données, validation de format, mise en page : inutile de réécrire en impératif ce que SQL, une expression régulière ou un moteur de règles fait mieux.
La recommandation pour un système multi-utilisateurs

Un gestionnaire de stock consulté et modifié par plusieurs utilisateurs simultanément touche le point 2 de plein fouet. Deux réceptions de marchandise enregistrées en même temps sur la même référence, et la quantité devient fausse sans qu'aucune exception ne soit levée.

Le scénario tient en six échanges, et rien n'y est anormal pris isolément. La réception A apporte 1 clavier, la réception B en apporte 2. Sur le diagramme ci-dessous, regarder les deux premières réponses du stock : elles donnent la même valeur, 12, à deux interlocuteurs différents. Tout le défaut est déjà là.

Diagramme de séquence
Réception AStockRéception Blire la quantité12lire la quantité12écrire 12 + 1 = 13écrire 12 + 2 = 14 | l'ajout de A vient d'être écrasé

Deux ajouts ont eu lieu, un seul subsiste : le stock affiche 14 au lieu de 15, l'ajout de A ayant disparu. Aucune exception n'est levée, aucun test unitaire ne le voit, et le défaut n'apparaît que sous charge.

C'est exactement ce qu'un cœur fonctionnel évite. Une fonction pure ne peut pas écraser un état qu'elle ne détient pas : elle calcule un nouvel état à partir de celui qu'on lui donne. C'est alors à la couche qui enregistre de refuser une écriture fondée sur une lecture périmée, et elle en a le moyen, puisque l'état lu lui a été transmis.

La réponse en pratique est mixte, et c'est la plus défendable :

  • la persistance déclarative confiée à une base de données, qui gère les accès concurrents et les contraintes d'intégrité ;
  • un cœur métier fonctionnel, fait de fonctions pures qui calculent le nouvel état sans le modifier, donc testable et parallélisable ;
  • une façade objet mince pour l'interface, là où la notion de session a un sens.

Ce qu'il faut écarter explicitement, c'est le procédural à état global : il n'offre aucune barrière contre les accès concurrents.

Exercices type

Le même calcul de valeur totale, en procédural et en fonctionnel. Qu'est-ce qui change vraiment ?

En procédural : total = 0 puis une boucle qui accumule en modifiant total. On décrit comment accumuler.

En fonctionnel : reduce(lambda t, p: t + p[1] * p[2], stock, 0), ou plus simplement sum(q * pr for _, q, pr in stock). On décrit ce qu'est la valeur totale : la somme des produits quantité × prix.

Ce qui change réellement : dans la première version, total passe par des valeurs partielles, qui ne sont pas encore la valeur totale, et un return mal placé ou une boucle interrompue en renverrait une. Dans la seconde, non : l'expression n'a 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 mentalement.

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 valeur_totale((("clavier", 15, 45.0),)) == 675.0.

Une fonction impure suppose un état préalable : il faut le construire avant l'appel, puis vérifier après coup qu'il a été correctement modifié. Le test devient un scénario, sensible à l'ordre d'exécution des autres tests.

C'est aussi ce qui rend les fonctions pures mémoïsables : on peut mettre leur résultat en cache sans risque, puisque la même entrée donnera toujours la même sortie.

Java est-il un langage orienté objet ?

Historiquement oui, et exclusivement : tout devait vivre dans une classe.

Aujourd'hui, non, plus exclusivement. Depuis Java 8, il a des expressions lambda, des références de méthodes, des Stream et des Optional : de quoi écrire dans un style franchement fonctionnel.

La bonne formulation est donc : Java encourage l'objet par sa structure, mais permet le fonctionnel. C'est le cas de presque tous les langages courants, et c'est pourquoi « le langage X est objet » est une phrase à nuancer.

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

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

Python ne l'empêche pas techniquement : stock._produits reste accessible. Contrairement à Java ou C++, l'encapsulation y est une convention entre développeurs, pas une contrainte du compilateur.

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 le dictionnaire par une base de données ne casse aucun code appelant.

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

Fonctionnel, et la raison est précise : les 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 ni condition de course, ni interblocage, la classe de bugs disparaît par construction.

En procédural ou en objet avec état mutable partagé, il faudrait protéger chaque accès par un verrou : code plus complexe, plus lent, et affecté de bugs qui ne se reproduisent pas.

Nuance honnête : cela suppose que le traitement se décompose en parties indépendantes. Un calcul intrinsèquement séquentiel, 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 ; 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, 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.

La méthode

  1. Nommer le paradigme avant de décrire une solution : « approche fonctionnelle » situe tout ce qui suit.
  2. Identifier où vit l'état. C'est la question qui distingue les quatre approches, et le point de départ de toute comparaison.
  3. Écrire la version la plus simple d'abord. Le procédural sert de référence pour mesurer ce que les autres apportent.
  4. Comparer sur des critères explicites (état, testabilité, concurrence, lisibilité) et pas sur une préférence.
  5. Citer le coût de l'approche qu'on recommande. Une recommandation sans inconvénient n'est pas une recommandation.
  6. Répondre à la contrainte de l'énoncé. « Multi-utilisateurs » veut dire concurrence, et la concurrence appelle l'immuabilité.

Synthèse

  • Un paradigme est une façon de structurer un programme : ni un langage, ni un niveau, ni une religion.
  • Impératif / procédural : comment faire. Instructions et procédures, état global. Simple et direct, difficile à faire grandir.
  • Orienté objet : qui fait quoi. Données et opérations regroupées, état encapsulé, contraintes vérifiées en un seul point.
  • Fonctionnel : qu'est-ce que c'est. Valeurs immuables, fonctions pures, aucun effet de bord. Testable et parallélisable, plus coûteux en mémoire.
  • Déclaratif : quoi obtenir. On décrit le résultat, un moteur trouve le chemin. Excellent dans son domaine, opaque au débogage.
  • La vraie ligne de partage est l'état mutable partagé, d'où viennent la plupart des bugs de concurrence.
  • L'affectation ne copie pas : deux noms peuvent désigner le même objet.
  • Les langages sont multi-paradigmes ; le choix appartient au programmeur.
  • Une architecture réelle mélange : persistance déclarative, cœur fonctionnel, façade objet.

Et ensuite

Savoir structurer un programme ne dit pas encore ce qu'il coûte : deux versions également lisibles peuvent différer d'un facteur mille sur les mêmes données. Complexité et Green IT donne l'outil qui prévoit cet écart avant d'écrire le code.

Mettre en pratique