Quelques Conseils Pragmatiques
pour
le Développement des Logiciels
CMAP (Centre de Mathématiques APpliquées) UMR CNRS 7641, École polytechnique, Institut Polytechnique de Paris, CNRS, France
france telecom, France Telecom R&D
[Site Map, Help and Search [Plan du Site, Aide et Recherche]]
[The Y2K Bug [Le bug de l'an 2000]]
[Real Numbers don't exist in Computers and Floating Point Computations aren't safe. [Les Nombres Réels n'existent pas dans les Ordinateurs et les Calculs Flottants ne sont pas sûrs.]]
[N'oubliez pas de visiter Une Machine Virtuelle à Explorer l'Espace-Temps et au-delà où vous trouverez plus de 10.000 images et animations à la frontière de l'Art et de la Science]
(Site WWW CMAP28 : cette page a été créée le 14/05/1997 et mise à jour le 03/10/2024 17:08:26 -CEST-)
[in english/en anglais]
Le plan de ce document est le suivant :
1-INTRODUCTION :
L'informatique est omniprésente dans les laboratoires de recherche,
et en particulier au Centre de Mathématiques APliquées où des activités de
type simulation numérique occupent la plus grande
partie des ressources informatiques. Une simulation numérique se décompose
en quatre étapes qui agissent rétroactivement l'une sur l'autre
(au cours de la mise au point) :
- l'analyse mathématique du problème,
- la programmation du modèle numérique associé,
- la calcul proprement dit,
- l'analyse des résultats.
Etant donné qu'une chaîne n'a que la résistance de son maillon le plus faible,
il est essentiel que les trois dernières étapes (qui sont liées directement à l'usage
de l'ordinateur) soient conduites avec la même rigueur que la première ! Nous allons
donc donner ci-après quelques conseils destinés à :
- rendre les logiciels indépendants des systèmes utilisés
et de leurs architectures (autant que faire se peut...),
- faciliter l'évolution et le suivi des logiciels au cours du temps,
- assurer la documentation de façon auto-suffisante,
- favoriser la réutilisabilité des développements antérieurs,
- limiter le nombre des erreurs de logique et de programmation,
- permettre de contourner les anomalies (bugs)
malheureusement inévitables à tous les niveaux des
systèmes informatiques utilisés,
- faciliter l'évaluation de l'importance des erreurs d'arrondi.,
Tous les conseils qui seront donnés dans les lignes qui suivent sont tous d'une
nature très pragmatique et aucun d'entre-eux ne reposent sur des outils logiciels
non standards. Enfin, la seule hypothèse qui sera faite par la suite consiste
à supposer qu'un système de type UNIX est utilisé et ce, afin de donner des
exemples concrets (mais celle-ci est peu contraignante et le tout se transpose très bien
à tout autre système).
2-L'ENVIRONNEMENT DE TRAVAIL :
2.1-Configuration :
Pour simplifier la présentation, nous
allons supposer que l'interaction avec le système
se fait en utilisant le 'C-Shell' (csh) ; celui-ci
présente deux avantages importants :
- sa syntaxe est proche de celle du C,
- il possède un mécanisme d'historique des commandes (mais il n'est
pas le seul...).
Dans ces conditions, l'environnement de travail peut être
paramétré à l'aide d'un certain nombre de fichiers qui doivent être
localisés au premier niveau de l'arborescence de l'utilisateur
(dit home directory). Examinons les plus importants
d'entre-eux :
2.1.1-le fichier .cshrc :
Lorsqu'un csh est créé,
après avoir accompli un certain nombre d'actions que
l'utilisateur standard n'a pas la possibilité de
paramétrer, il exécute des commandes spécifiques
placées dans le fichier .cshrc. Elles
permettront d'une part de définir des variables
dites d'environnement (par
exemple la variable history
qui donne le nombre de commandes mémorisables) et
d'autre part de définir des commandes personnalisées
dites alias (par exemple
celle qui permettra de lister tous les fichiers
d'un directory). Voici un exemple minimal de
fichier .cshrc :
#!/bin/csh
set history=1000
alias ll 'ls -al'
Ces mécanismes sont extrêmement performants ; il est par
exemple très facile de se définir une commande qui explore
tous les programmes C contenus dans l'arboresence
de l'utilisateur et recherche l'occurence de certaines
chaînes de caractères. Il est à noter au passage la
puissance des moyens de communication dits
pipes (symbolisés par le
caractère '|'). Ainsi, par exemple :
ll | more
permet de lister tous les fichiers d'un directory
par groupes de N (ou N est en général le nombre de
lignes de la fenêtre utilisée).
S'il est ainsi très facile de se construire des outils
très efficaces, malheureusement il est une
difficulté qui ne doit pas être négligée : celle de
portabilité. A titre d'illustration,
il est intéressant de comparer les arguments possibles
et les formats de sortie de différentes commandes
de base (par exemple 'ps', 'ls', 'find',...)
sur des systèmes différents...
2.1.2-le fichier .login :
Après avoir exécuté le fichier .cshrc,
si le csh est le premier à être
exécuté au cours de la session courante
(donc après un login),
le fichier .login est exécuté
à son tour. Il contient lui-aussi des commandes à
interpréter et utiles à l'utilisateur ; ainsi,
il y aura par exemple un ordre de destruction de tous
les fichiers temporaires créés antérieurement et toujours
présents, ou encore une consultation de la boîte
à lettres...
2.1.3-le fichier .logout :
Le fichier .logout est en
quelque sorte le symétrique du fichier .login.
Il est interprété lors de la fin de la session courante
(donc après un logout). Il pourra
lui-aussi contenir un certain nombre d'actions
de nettoyage.
2.2-Les fichiers et leur arborescence :
Dans son home directory l'utilisateur
va ranger des fichiers correspondant aux courriers qu'il reçoit
ou qu'il émet, aux programmes qu'il rédige,
aux données qu'il traite, aux résultats qu'il
produit... Il est ainsi très facile de posséder plusieurs
centaines de fichiers (et même beaucoup plus...). Il
est donc impératif de les classer de façon logique ; il
est ici fortement déconseillé de s'inspirer de l'arborescence
du système UNIX dont l'ordre (le non-ordre ?)
est l'héritage d'une longue évolution à laquelle ont participé
des milliers de chercheurs et d'ingénieurs...
A titre d'exemple, voici une structure simpliste possible :
|archives
|-------------------
| |courriers
| |-------------------
| | |in
| | |--
| | |
| | |out
| | ---
| |articles
| --------
|donnees
|-------
|
|resultats
|-------------------
| |bruts
| |-----
| |
| |images
| -------------------
| |animations
| |----------
| |
| |fixes
| -----
|programmes
-------------------
|C
|-------------------
| |sources
| |-------
| |
| |executables
| -----------
|Fortran
-------------------
|sources
|-------
|
|executables
-----------
Un point important sur lequel
nous reviendrons plus loin
est celui de la sécurité (vis à vis de soi-même,
des maladresses, ou encore de la malveillance...). Le plus simple
est de donner le moins de droits possibles à ses fichiers par rapport à
l'utilisation qui en sera faite. Pour un source non confidentiel,
le mode 644 peut être utilisé, alors que 600 permettra de garantir
une certaine confidentialité. Enfin, pour les "maniaques",
voire les paranoïaques,
il est possible de mettre les fichiers en mode 400 qui interdit à
quiconque (y compris à soi-même !) de les modifier ; une modification
impliquera alors une démarche volontaire...
Enfin, un point important concerne la duplication d'un même
fichier sur plusieurs machines lorsqu'un système de partage de type
'NFS' n'est pas utilisé. Il est alors essentiel de considérer l'un
des ordinateurs comme une
machine de référence immuable sur
laquelle les modifications pourront avoir lieu, puis seront propagées
(par une opération de transfert de fichier) ; dans le cas où une
telle recommandation n'est pas suivie, il est pratiquement
impossible de faire de façon fiable les mises à jour des différentes
versions d'un même programme sur plusieurs systèmes.
2.3-A propos de la sécurité :
Sans sombrer dans la paranoïa la plus profonde, les
problèmes lies à la sécurité ne doivent pas être sous-estimés,
voire ignorés. En effet, même si les activités d'un certain utilisateur
n'ont rien de confidentielles (et a fortiori
si elles le sont...), au moins trois raisons doivent contraindre
à prendre quelques précautions :
- Meme si la fiabilité des matériels et des logiciels
a fait des progrès considérables,
un disque, un processeur, ou encore
de la mémoire
(aux MTBFs -Mean Time Between Failure-
souvent impressionnants...),
peuvent tomber en panne et provoquer la perte de tout
ou partie des informations mémorisées.
A titre d'exemple, le 25/07/1997 une parité
mémoire sur la station de travail de l'auteur a corrompu
un directory et a ainsi provoqué la perte de nombreux
fichiers.
- L'erreur est humaine (et fréquente lorsqu'un
ordinateur est utilisé...). Nos propres erreurs
de manipulation, mais aussi celles de
ceux qui partagent nos ressources
ou qui administrent éventuellement nos
systèmes, peuvent avoir des conséquences
dramatiques. A titre d'exemple vécu, un
ingénieur système, il y a quelques
années, détruisit l'intégralité des
fichiers de l'ensemble de ses utilisateurs
(arborescence /usr) en
voulant détruire uniquement les fichiers de
type core...
- Aujourd'hui, il n'est pratiquement plus
de systèmes isolés. Les utilisateurs d'Internet
se comptent par millions ; il suffit donc d'un
pourcentage très faible de "plaisantins" ou encore de
"terroristes" pour que la menace soit réelle.
Il est important de comprendre qu'il est
réellement des esprits pervers et les très nombreux
virus qui existent de par le monde de la
micro-informatique, en particulier,
sont là pour nous le rappeler.
Quelques précautions simples et peu contraignantes peuvent et doivent
donc être prises :
- Des sauvegardes (ou backup)
doivent être faites régulièrement et vérifiées simultanément ;
quoi de plus simple que de lancer cette opération (automatique...)
tous les soirs en utilisant quelques supports
permutés circulairement ?
- Des mots de passe non triviaux doivent être
utilisés. En effet, l'un des premiers
moyens utilisés pour forcer l'entrée dans un
système (dès qu'au moins le nom de l'un de ses
utilisateurs est connu) consiste à essayer
comme mot de passe tous les mots d'un dictionnaire
et quelques transformations de ceux-ci
(par exemple, des permutations de leurs lettres).
Il convient donc d'utiliser des mots de passe
ne possédant de sens que pour l'utilisateur
lui-même (afin d'en simplifier la mémorisation)
et si possible différent d'une machine
à l'autre, lorsque plusieurs sont utilisées.
Enfin, s'il est évident que les faiblesses
du mot de passe d'un utilisateur peuvent avoir des
conséquences désastreuses pour celui-ci,
il faut bien noter qu'il peut en être de même
pour ceux qui partagent les mêmes
ressources, car, il est en effet
beaucoup plus facile d'attaquer un système
en étant sur place (quel que soit le numéro de
compte) qu'en en étant à l'extérieur. La
sécurité est dans ce domaine une responsabilité
partagée...
A tout cela ajoutons quelques conseils (ils sembleront à certains bien
évidents, voire naïfs, alors qu'il ne s'agit là que de bon sens...) :
- La sécurité ne doit pas reposer sur un seul dispositif,
mais au contraire sur plusieurs, qui doivent être
pour certains redondants et pour d'autres complémentaires
(ne pas mettre tous ses œufs dans le même panier).
- Il ne faut pas croire qu'un système réputé très fiable est à
l'abri des défaillances (c'est par exemple le cas des systèmes
de disque RAID) ; son utilisation ne doit donc
pas assoupir la vigilance de l'utilisateur.
- Il ne faut pas faire reposer sa propre securité sur des
systèmes dont le fonctionnement n'est pas testé
périodiquement (ils doivent exister et être
opérationnels...).
Mieux vaut prévenir que guérir
et Un homme averti en vaut deux.
3-L'ART DE LA PROGRAMMATION :
Tous les conseils et toutes les remarques qui seront présentés dans les lignes qui
suivent sont indépendants des langages de programmation utilisés. Il est important
de noter qu'il est possible de faire de la belle programmation en assembleur (même
si cela est fortement déconseillé pour des raisons de portabilité principalement) et de très
mal s'exprimer avec des langages plus "modernes" comme le C++...
Programmer doit se faire en ayant présent à l'esprit
les trois grands principes suivants :
- simplicité,
- généralité,
- harmonie.
L'expérience montre qu'ils ne sont pas inconciliables et qu'il est toujours
bon de généraliser pour simplifier (paraphrasant Jacques Hadamard
qui s'exprimait ainsi au sujet des Mathématiques).
A propos de programmes ainsi conçus, il est alors intéressant de rappeler
la remarque prophétique faite par Heinrich Hertz au dix-neuvième siècle :
on ne peut échapper au sentiment que ces formules mathématiques
ont une existence qui leur est propre, qu'elles sont plus savantes que
ceux qui les ont découvertes, et que nous pouvons en extraire plus de science
qu'il n'en a été mis à l'origine, à condition de substituer le mot
programme à l'expression
formule mathématique.
Notons dès à présent qu'un exemple C sera donné ensuite
afin d'illustrer ces concepts et qu'il peut être consulté systématiquement
(un exemple K sera aussi disponible).
3.1-De la lisibilité des programmes :
My shtick is to promote the idea that humans,
not computers, read programs (Donald Knuth).
Malheureusement, trop de programmeurs ont tendance à
oublier le fait qu'ils sont (ainsi que leur collègues) les
premiers et les principaux lecteurs de leurs programmes ;
si l'ordinateur ne fait pas la différence entre deux programmes
fonctionnellement identiques mais de style différents,
il n'en est pas de même de l'être humain. Or, les
langages de programmation actuels (et le C en particulier)
permettent (incitent à ?) l'écriture de programmes incompréhensibles.
A titre d'exemple, voici le plus petit programme connu
(en 1994) qui permet de calculer pi=3,141592653... avec plusieurs milliers de décimales (2399 pour être précis) :
int a=10000,b=0,c=8400,d,e=0,f[8401],g;
main(){for(;b-c;){f[b++]=a/5;}for(;d=0,g=c*2;c-=14,printf("%.4d",e+d/a),e=d%a)
{for(b=c;d+=f[b]*a,f[b]=d%--g,d/=g--,--b;d*=b);}}
(reproduit à partir du numero 199 de Mai 1994 de Pour La Science
et dont l'auteur est inconnu). Cet exemple peut paraître caricatural,
mais malheureusement, de nombreux programmes (plus utiles) ont
bien souvent cette apparence ésotérique. S'il est vrai qu'il s'agit
bien d'une performance, son auteur aurait peut-être beaucoup
de mal, plusieurs années après, à comprendre comment il
fonctionne et à reconnaître tout simplement la formule utilisée...
Au passage, celle-ci semble être la suivante :
U = CONSTANTE
0
U
k-1
U = ------------(N-k) + CONSTANTE
k 2(N-k) + 1
avec : k ∈ [1,N-1],
le nombre de décimales calculées est égale au logarithme décimal
de la constante CONSTANTE (qui elle-même doit être égale à 2 fois une
puissance de 10 arbitraire) : quant au nombre d'itérations N,
il est égal au nombre de décimales multiplié par 7/2.
Voici de même le plus petit programme connu (rédigé par Xavier Gourdon) qui permet de calculer (très rapidement...)
e=2,718281828... avec environ 100000 décimales (le paramètre 'N' peut être modifié à loisir) :
main(){int N=100000,n=N,a[N],x;while(--n)a[n]=1+1/n;
for(;N>9;printf("%d",x))
for(n=N--;--n;a[n]=x%n,x=10*a[n-1]+x/n);}
Là-aussi, l'algorithme utilisé est loin d'être évident...
[consulter l'exemple C]
[consulter l'exemple K]
3.2-Des commentaires utiles :
Ceux-ci doivent accompagner généreusement le code, et
être rédigés simultanément, voire au préalable, et
non point a posteriori, lorsque le fil du raisonnement se
sera émoussé.
Par la suite, lorsque ce programme subira des mises à jour,
les commentaires correspondant devront, eux-aussi, être modifiés.
Pour améliorer la présentation et la lisibilité du
programme, il est de loin préférable de rédiger
des lignes de commentaires distinctes des instructions
elles-mêmes, dont la longueur et la tabulation
soient constantes et uniformes à l'intérieur d'un ensemble
de logiciels (remarque tout aussi valable
pour les instructions d'ailleurs...), tout en distinguant clairement
ceux qui constituent des séparateurs à l'intérieur d'un même fichier,
des commentaires d'instructions
(par exemple, en les faisant débuter en première
et quarantième colonne respectivement).
Toute ligne commentaire devra être matérialisée en tant que tel,
c'est-à-dire commencer et se terminer par des indicateurs de début et
de fin ("/*" et "*/" pour un programme en C) même si elle
fait partie d'un ensemble plus volumineux.
Bien entendu cette contrainte
peut être vue comme une perte de temps, mais il convient
de ne pas oublier que cela peut être réalisé
facilement par des duplications ou bien encore des opérations du
type "copier-coller" ("copy and paste").
De plus, lors du commentaire de formules
complexes, il est en général utile de les faire
apparaître sous leur forme naturelle (mathématique), avec
par exemple des traits de fraction, des
"sigmas",... Encore une fois tout cela peut paraître
pénible, mais avec un peu d'habitude et
d'entraînement, la chose est tout à fait possible.
D'autre part, il n'est pas inutile parfois d'indiquer
clairement les modifications apportées par
rapport à une version antérieure lors de la correction d'un
défaut (qu'il soit interne ou externe -par
exemple "trappe" d'un compilateur-), ou bien
encore les raisons de tel ou tel autre choix. Il est
alors important d'indiquer la date à laquelle a été
réalisée cette opération. A ce propos, il est
suggeré de plus de mettre la date dans le
format universel 'AAAAMMJJhhmmss'
pour lequel l'ordre alphabétique est
aussi l'ordre numérique et qui facilite ensuite d'éventuelles
recherches automatiques ; Il convient de noter au passage que la
commande UNIX :
date "+%Y%m%d%H%M%S"
réalise automatiquement cette mise en forme sur la plupart
des systèmes accessibles.
Il est de plus suggéré de mettre en place des liens inter-fichiers
permettant de préciser les relations qu'ils entretiennent entre-eux.
Par exemple, si un certain algorithme a été mis au point
à l'aide d'un programme P1 pour être utilisé ensuite dans un
programme P2 plus complexe, il sera utile de l'indiquer
dans P1 et dans P2. Cela est aussi vrai pour des fichiers de
données, de résultats ou encore des images visualisant
ceux-ci.
Le résultat obtenu est alors un véritable outil de travail
auto-suffisant (dans le sens où aucun
document accessoire n'est plus nécessaire...).
[consulter l'exemple C]
[consulter l'exemple K]
3.3-De la paramétrisation :
Imaginons qu'à l'examen d'un programme, nous
trouvions,
par exemple, dans deux instructions la constante 511 ;
la question qui se pose alors est de savoir si
les deux occurences du nombre 511 sont attachées au même objet ? Et
cette (ou ces) constante(s)
511 a-t-elle (ont-elles) un rapport avec la constante
512 rencontrée quelques lignes auparavant...
Cela correspond en fait à la triste réalité : nombreux
sont en effet les programmes où abondent ainsi
des valeurs numériques littéralement parachutées, et où
toutes les
relations qu'elles peuvent entretenir ont complètement disparues.
En fait, quoi de plus facile que de définir des constantes
fondamentales (des "atomes") en les
plaçant, par exemple, dans un fichier
unique, puis en les référencant symboliquement et sans
exception (le source du programme cpp
contient ainsi une définition dont l'usage n'est pas
systématique...). Ensuite, à partir d'elles,
des "molécules" de plus en plus complexes seront
assemblées ; elles permettront de comprendre alors,
en reprenant l'exemple ci-dessus, que l'une des
occurences de la constante 511 désignait en fait l'ordonnée
maximale de l'axe des ordonnées, alors que l'autre représentait
un masque de 9 bits (511 étant égal a 2 puissance 9 moins 1)...
Il pourrait paraitre banal de dire qu'une telle pratique :
- respecte la logique et la structure des
objets manipulés,
- simplifie la rédaction d'un programme,
- améliore sa lisibilité
- facilite ensuite grandement sa
mise à jour (en particulier avec des méthodes automatiques),
mais une fois encore, l'expérience montre que
peu nombreux sont ceux qui profitent de ses
bienfaits, quel que soit le langage de
programmation utilisé... Ce
qui pourrait être qualifié ici de naïveté n'est en
fait que du bon sens...
Cette approche "chimique" de la paramétrisation peut et doit aussi se retrouver
dans la définition des structures, des opérateurs, des fonctions,... utiles.
[consulter l'exemple C]
[consulter l'exemple K]
3.4-Contre le laxisme :
Pour alléger la tâche du
programmeur, de nombreux langages
autorisent, dès que la chose est
possible, certaines
simplifications et ne font pas de la syntaxe, un
carcan suffisamment rigide. Si cela peut effectivement
faire gagner quelques précieuses secondes
lors de la création d'un nouveau programme,
ces facilités
peuvent a posteriori se révéler nuisible,
voire dangereuse. Par exemple,
en C, il
est possible d'écrire :
if ( < condition > ) < instruction non composée 1 >
(le point-virgule étant ici inclu dans la définition
d'une instruction non composée) ; puis,
supposons que, lors d'une évolution du programme
ou à l'occasion d'un test, une deuxième
instruction (par exemple l'édition d'un message),
soumise à la même condition soit à rajouter à la
suite de la première ; cela ne s'écrira pas (même si
la forme suivante est syntaxiquement correcte et
d'une signification toute différente...) :
if ( < condition > ) < instruction non composée 1 > < instruction non composée 2 >
mais :
if ( < condition > ) { < instruction non composée 1 > < instruction non composée 2 > }
dans ces conditions, pourquoi ne pas contraindre
à écrire dans tous les cas :
if ( < condition > ) { < instruction > }
et donc en particulier :
if ( < condition > ) { < instruction non composée 1 > }
Ainsi que le montre clairement ce petit exemple, une telle
contrainte facilite la mise à jour
ultérieure et permet même certaines modifications automatiques (par
exemple à l'aide de fichiers de
commandes destinés aux programmes d'édition et de manipulation de
textes -'sed', 'awk',...-). Il
est possible d'aller encore plus loin sur cette voie ; en reprenant
l'exemple du test, la présence d'une
séquence alternative (éventuellement vide) pourrait être rendue
obligatoire, soit :
if ( < condition > ) { < instruction 1 > } else { < instruction 2 > }
Bien évidemment, cette suggestion s'étend à de nombreuses
autres structures du C (par exemple aux
boucles 'for') et bien entendu, aux autres langages de programmation.
La compréhension de la structure des programmes se
trouve ainsi grandement ameliorée, en
particulier lorsque de nombreux tests se trouvent imbriqués
les uns dans les autres et/ou avec
d'autres constructions.
[consulter l'exemple C]
[consulter l'exemple K]
3.5-Des symboles évocateurs :
Toujours dans l'idée de faciliter la compréhension d'un
programme, il est évident que le rôle joué par
les symboles utilisés pour désigner les objets
manipulés, est important. Pourquoi abuser de noms
du type 'X123' ou 'NDG12A', alors que
'position_de_la_source_lumineuse' ou 'parametre_d_interpolation'
sont beaucoup plus évocateurs,
correspondent à la logique du problème traité
(notons au passage que c'est en particulier l'absence d'une terminologie
homogène qui rend impossible l'automatisation du traitement du
problème de l'An 2000)
et enfin demandent un effort de mémorisation beaucoup moins
important, voire nul ?
Il pourrait être
répondu à cela, qu'il est plus beaucoup plus
facile de taper sur un clavier 'X123' que
'position_de_la_source_lumineuse' ; en fait cet argument n'est
d'aucune valeur, car en effet,
les opérations "copier-coller" ("copy and paste"),
éventuellement associées à une fenêtre spéciale de type
"scratch pad", peuvent et
doivent être utilisées. De plus, ce
type de symbole évocateur limite les risques d'erreur
liés a l'usage de
symboles "voisins" l'un de l'autre (par exemple 'X123' et 'X213').
Enfin, lors de l'utilisation de langages tel le
Fortran, ou les variables scalaires, par
exemple, peuvent être définies implicitement,
il convient de renoncer à cette facilité, et de les
déclarer explicitement afin d'éviter de voir le compilateur
créer plusieurs variables différentes, par exemple
à la suite de fautes de frappe du programmeur...
[consulter l'exemple C]
[consulter l'exemple K]
3.6-De la difficulté de comprendre une expression :
La syntaxe d'un langage de
programmation peut être qualifiée de linéaire,
alors que celle qui est utilisée en mathématiques ne
l'est en général pas (notons dès à present,
que l'un de nos
objectifs, est ici de rétablir la "non
linéarité" au niveau de la programmation).
Par exemple, les deux formules arbitraires suivantes :
f5(x)-a
y1 = f1(f2(f3(x)*f4(x)) + --------- + f7(f8(x)*f9(x)))
f6(x)-b
f2(f3(x)*f4(x)) + (f5(x)-a)
y2 = f1(-----------------------------)
(f6(x)-b) + f7(f8(x)*f9(x))
s'écrivent (dans un certain langage de programmation) :
y1 = f1(f2(f3(x)*f4(x)) + (f5(x)-a)/(f6(x)-b) + f7(f8(x)*f9(x)))
y2 = f1((f2(f3(x)*f4(x)) + (f5(x)-a))/((f6(x)-b) + f7(f8(x)*f9(x))))
Cette dernière écriture :
- est relativement illisible,
- ne facilite pas la compréhension des
deux formules, les
grandes structures présentes (et en
particulier les deux formes principales
f1(A+B+C)
et f1(P/Q)) n'étant pas mises en évidence,
- est ambigüe (puisque les deux formules paraissent à
première vue pratiquement identiques...).
Or malheureusement, il est possible d'exhiber facilement des
exemples d'une complexité très
supérieure, en particulier dans l'univers de la programmation
scientifique. La linéarité du source fait
perdre la structure arborescente de la formule et en limite par là-même
la compréhension (tout en rendant difficile sa mise à jour...).
Pour améliorer la lisibilité des formules, une
première solution consiste à introduire
des variables intermédiaires correspondant à quelques
sous-expressions choisies, si possible, parce qu'elles
possèdent une certaine signification par rapport au
problème traité. Il sera ainsi possible d'écrire :
v11 = f1(f2(f3(x)*f4(x))
f5(x)-a
v12 = ---------
f6(x)-b
v13 = f7(f8(x)*f9(x)))
y1 = v11 + v12 + v13
et :
v21 = f2(f3(x)*f4(x)) + (f5(x)-a)
v22 = (f6(x)-b) + f7(f8(x)*f9(x))
y2 = f1(v21/v22)
Une deuxième solution, permettant de restituer visuellement cette
structure directement, consiste à
faire des retours à la ligne fréquents associés à des
tabulations systématiques et logiques. Ainsi
sera clairement exhibée la structure de la formule
programmée, cette propriété se conservant
quelqu'en soit la complexité. Ainsi,
nos deux expressions précédentes pourraient alors s'écrire :
y1 = f1(f2(f3(x)
*f4(x)
)
+((f5(x)-a)
/(f6(x)-b)
)
+f7(f8(x)
*f9(x)
)
)
y2 = f1((f2(f3(x)
*f4(x)
)
+(f5(x)-a)
)
/((f6(x)-b)
+f7(f8(x)
*f9(x)
)
)
)
ou bien encore, dans une version "allégée" :
y1 = f1(f2(f3(x)*f4(x))
+((f5(x)-a)/(f6(x)-b))
+f7(f8(x)*f9(x))
)
y2 = f1((f2(f3(x)*f4(x))+(f5(x)-a))
/((f6(x)-b)+f7(f8(x)*f9(x)))
)
Dans les deux écritures précédentes, il convient
de bien noter l'utilisation de parenthèses redondantes,
utiles à la structuration, à la compréhension et à la
modification des formules...
Il s'agit alors d'une programmation qu'il serait possible de
qualifier de visuelle.
[consulter l'exemple C]
[consulter l'exemple K]
3.7-Prévoir les anomalies :
Dans l'état actuel, aucune méthode ne permet d'éliminer
de façon certaine toutes les erreurs de conception.
Malheureusement, celles-ci peuvent
échapper aux tests préliminaires pour n'apparaître que
beaucoup plus tard. Une aide alors utile, peut être
d'insérer, dans les programmes, des tests de
validité de certaines des valeurs calculées.
Par exemple, si une grandeur ne peut qu'être
strictement positive, un test vérifiant cette
condition pourra être ajouté après son calcul ; en cas de
violation, les différentes valeurs utiles à son
obtention devront être éditées afin de faciliter la
correction de l'anomalie.
Celà signifie de plus que la notion d'hypothèse implicite
doit être à proscrire absolument. Ainsi, par
exemple, même si un paramètre d'appel d'un
programme ou d'une fonction n'a de sens que positif
(un "nombre de"), il est essentiel
de vérifier explicitement qu'il en est bien ainsi ; dans le
cas contraire, il conviendra d'éditer des messages
significatifs puis, suivant les circonstances,
d'interrompre l'exécution ou de forcer une valeur par défaut
(ceci devant être indiqué clairement...). A titre d'exemple,
un grand constructeur distribue un simulateur de machines parallèles
sur machines séquentielles UNIX. Ce logiciel manipule systématiquement
les numéros de processeurs virtuels (ainsi que les paramètres
associés) à l'aide de décalages, faisant ainsi l'hypothèse
implicite que le nombre de processeurs demandés par l'utilisateur
est une puissance de 2. Malheureusement, cela n'est pas dit
et n'est jamais testé. Il est donc possible, par exemple,
de demander 5 processeurs virtuels ; la simulation se bloque alors
rapidement, sans explications...
Malheureusement, l'environnement de travail peut lui-même
engendrer des anomalies.
Ce qui va suivre ne doit pas inciter les programmeurs
à rendre systématiquement responsables les systèmes
informatiques des problèmes rencontrés, la
plupart d'entre-eux trouvant leur origine dans des
erreurs qu'ils ont commises. Malgré tout,
il faut savoir que les ordinateurs ne sont pas infaillibles
(voir à ce propos le problème des erreurs d'arrondi).
A titre d'exemple,
avec les versions 5.0 et 5.1 du système
IRIX Silicon Graphics, le
compilateur 'cc', lors de l'initialisation de
variables flottantes avec des quotients de constantes
entières de signes opposés,
donnent des résultats aberrants. Par exemple :
double variable;
(...)
variable = (-1)/1;
attribue à 'variable' la valeur 4294967295.0 ; il faut noter
au passage que cette écriture, telle qu'elle figure
ici, peut sembler sans intérêt ; en fait, il
n'en est rien,
ainsi que cela fut dit
au paragraphe 3.3 relatif à la paramétrisation !
De même,
avec la version 2.7.1 du compilateur
'gcc' sur DEC VAX sous ULTRIX, l'expression :
0
---
x
est interprétée comme :
1
-----
2.x
ce qui est mathématiquement peu correct !
Mais les compilateurs n'ont pas l'exclusivité des anomalies !
Les librairies, elles-aussi, peuvent présenter
des comportements aberrants ; ainsi,
avec les versions 6.0.1 et 6.1 du système
IRIX Silicon Graphics,
il est impossible de faire confiance à la fonction 'pow(...)'
de la librairie 'libm43'. A titre d'exemple, le calcul
des premières puissances de 2 donne :
0
2 = 1
1
2 = 2
2
2 = 4
3
2 = 363031686155.84345
4
2 = -2194234532479271.8
ce qui constitue, à partir de la puissance
troisième, une approximation relativement
grossière de la valeur exacte !
Une remarque essentielle s'impose alors : pour quelques erreurs
détectées (parce que leurs conséquences étaient "visibles"),
combien d'erreurs latentes sont présentes ?
Ainsi, un programme peut-être correct au niveau de son source,
alors que son exécutable peut, sur un certain système,
ne pas l'être !
Par exemple, cette image
semble correspondre visuellement à ce qui est attendu du programme
géniteur, mais est-elle ce qu'elle serait si ce programme
(supposé sans bug...) était exploité sur un système parfait ?
Une solution possible à ce délicat problème consiste à créer
une couche intermédiaire entre les programmes
de l'utilisateur
et les fonctions qu'il utilise et qui appartiennent à des
librairies du système. Ainsi, pour reprendre l'exemple
précédent, il suffit de se définir une unique fonction
'Fpow(...)' qui a priori ne fera rien d'autre qu'appeler
la fonction système 'pow(...)' lorsque tout va bien,
et qui en cas d'anomalies reconnues pourra entreprendre un
traitement de contournement plus ou moins complexe.
Cette fonction pourra être soit définie comme le sont habituellement celles
du langage utilisé ; pour des raisons de performance,
elle pourra aussi être définie par :
#define Fpow(x,y) pow((double)x,(double)y)
et ce de façon conditionnelle en fonction des éventuelles anomalies.
Notons au passage, que la directive '#define' est traitée
par cpp (le préprocessur du C) ; celui-ci étant en
fait indépendant du langage C,
il peut être utilisé avec tout autre langage
(le Fortran tout particulièrement).
Cette façon de faire possède un autre avantage : celui de
la centralisation logique. En effet, toutes les
références à un même "service" (à entendre dans un sens très large :
par exemple l'application d'un certain opérateur ou encore l'écriture d'un
fichier) passant par un point unique, il devient
trivial de modifier l'accès à celui-ci, puisque
ceci ne devra être fait qu'un en seul lieu. Il est
ainsi symptomatique de noter dans de nombreux programmes,
des sèquences de code strictement identiques et dupliquées
à différents endroits où elles remplissent des rôles similaires.
Cette façon de faire doit être évitée...
Il est enfin suggéré de posséder un "cahier de bord"
constitué d'un ou plusieurs fichiers dans lesquels
seront répertoriés les incidents, la date
à laquelle ils sont survenus, les actions
entreprises alors, les éventuels contacts extérieurs
utilisés pour les résoudre, les solutions de
contournement,... tout cela dans le but évident
de la réutilisabilité...
[consulter l'exemple C]
[consulter l'exemple K]
3.8-Assurer la compatibilité :
Lors de la corrections de "bugs", de l'amélioration et/ou de la modification de fonctionnalités antérieures,
de l'introduction de nouvelles fonctionnalités,... il conviendra d'introduire dans les programmes, les librairies,...
des options dites de compatibilité permettant d'exécuter ensuite les programmes tels qu'ils s'exécutaient
avant tout ou partie de ces modifications...
3.9-A propos du préprocesseur cpp :
L'éventuelle utilisation du préprocesseur cpp
devra être faite avec beaucoup de vigilance et de discipline.
En particulier, les arguments des macro-procédures,
lorsqu'ils existent, devront être systématiquement parenthésés afin
d'éviter des problèmes. Ainsi, les deux définitions suivantes :
#define Carre1(x) x*x
et :
#define Carre2(x) ((x)*(x))
sensées calculer le carré d'une certaine valeur 'x', donnent respectivement :
Carre1(2) ==> 2*2 = 4
et :
Carre2(2) ==> ((2)*(2)) = 4
ce qui est bien dans les deux cas le carré du nombre 2.
Alors que malheureusement, utilisées avec des arguments plus
"complexes", elles donnent respectivement :
Carre1(1+1) ==> 1+1*1+1 = 3
et :
Carre2(1+1) ==> ((1+1)*(1+1)) = 4
Ainsi, seule la deuxième écriture donne correctement le carré
de la somme '1+1', parce qu'en effet son argument 'x' est
utilisé entouré d'un couple de parenthèses.
Il conviendra donc de bien retenir que les macro-procédures
du préprocesseur cpp ne sont pas équivalentes,
en général, à des fonctions (au sens des langages de programmation),
le preprocesseur se contentant de faire de la substitution de chaînes
de caractères...
Ceci présente un avantage : les arguments des macro-procédures ne
sont pas typés et peuvent être absolument quelconques. Donnons un
exemple d'une application simpliste de cela ; imaginons que nous
ayons besoin de fonctions (au sens des langages de programmation)
calculant respectivement la somme, la différence, le produit
et enfin le quotient de deux nombres entiers. Il est évident que
ces quatre fonctions sont formellement identiques et
qu'elles ne se différencieront que par l'opérateur
utilisé et par la présence éventuelle d'un test de validation
des opérandes. La solution habituellement utilisées consiste
naturellement à écrire explicitement ces quatre fonctions. Une
autre solution consiste à programmer, à l'aide du
préprocesseur cpp, un générateur de ces
fonctions :
#define Generateur(fonction,operateur,sequence) \
int fonction(x,y) \
int x,y; \
{ \
int ox=x,oy=y; \
sequence; \
return(ox operateur oy); \
}
et de l'utiliser ainsi :
Generateur(addition
,+
,
)
Generateur(soustraction
,-
,
)
Generateur(multiplication
,*
,
)
Generateur(division
,/
,if (y == 0)
{
if (x == 0)
{
printf("\n ATTENTION : division indeterminee (0/0), une valeur arbitraire (0) va etre forcee.\n");
oy = 1;
}
else
{
printf("\n ATTENTION : division par 0, l'infini entier et signe va etre force.\n");
ox = 0x7fffffff;
oy = (x>=0 ? +1 : -1);
}
}
else
{
}
)
afin de créer les quatre fonctions de noms respectifs
addition,
soustraction,
multiplication et
division.
Encore une fois cet exemple est volontairement très simpliste
(et les fonctions générées peu utiles...), mais il est
évident que cela peut s'étendre sans aucune difficultés à des
"structures" plus complexes. Enfin, il conviendra de
ne pas oublier que ces quelques principes sont indépendants des
langages de programmation utilisés.
[consulter l'exemple C]
[consulter l'exemple K]
3.10-Rechercher la simplicité et promouvoir la modularité coopérative :
Le temps n'est plus où la programmation de
machines à la mémoire centrale de quelques kilo-octets se faisait en assembleur.
Les compilateurs d'aujourd'hui ont atteint, en particulier dans
le domaine de l'optimisation, un niveau de compétence que peu
d'entre-nous possèdent.
Par exemple, sur une O200 (R10000) Silicon Graphics
en version 6.4 du système IRIX et 7.1 des compilateurs,
le programme suivant (calculant un produit matriciel) :
#include < stdio.h >
#define N 500
int main()
{
double MatriceProduit[N][N],Matrice1[N][N],Matrice2[N][N];
double trace;
int i,j,k;
for (i=0 ; i < N ; i++)
{
for (j=0 ; j < N ; j++)
{
Matrice1[i][j] = i+j;
Matrice2[i][j] = i*j;
/* Initialisation arbitraire des deux matrices. */
}
}
for (i=0 ; i < N ; i++)
{
for (j=0 ; j < N ; j++)
{
MatriceProduit[i][j] = 0;
for (k=0 ; k < N ; k++)
{
MatriceProduit[i][j] = MatriceProduit[i][j]
+ ( Matrice1[i][k]
*Matrice2[k][j]
);
}
}
}
trace = 0;
for (i=0 ; i < N ; i++)
{
trace = trace + MatriceProduit[i][i];
}
printf("\n trace=%f\n",trace);
/* Le calcul de la trace, ainsi que le 'printf(...)' sont uniquement destines a utiliser */
/* les resultats calcules. En effet, sinon l'optimiseur reduit ce programme a neant */
/* puisqu'alors il ne sert effectivement a rien... */
}
(où sont mises en gras les lignes utiles au calcul du produit ; il est
à noter la présence d'un code calculant
et surtout éditant la trace de la matrice : ces lignes sont rendues nécessaires par
l'utilisation de l'optimiseur qui est capable de repérer un programme
qui ne sert à rien et qui dans ces conditions élimine alors l'intégralité des
instructions...)
s'exécute en un temps T (=28.5 secondes) lorsqu'il est compilé suivant :
cc -64
et en T/25 avec :
cc -64 -O3
Evidemment et malheureusement, ce rapport sera en général
moins élevé pour des programmes plus complexes et moins "académiques" ; malgré cela,
même dans des cas "très séquentiels", où les optimisations à
réaliser sont peu évidentes, des gains parfois surprenants
pourront être obtenus.
Dans ces conditions, pourquoi réduire fortement la lisibilité
du programme en introduisant d'obscures optimisations, alors que celles-ci
pourront être effectuées automatiquement et mieux par les
compilateurs (ces optimisations pouvant être, de plus,
non portables -en effet, bien souvent elles sont liées à l'architecture
matérielle des mémoires et des processeurs utilisés-...) ?
Cette remarque s'applique aussi bien aux
instructions de calcul arithmétique qu'aux tests logiques ; à
titre d'exemple, il sera souvent plus lisible de remplacer un
test complexe par une série de tests plus simples et imbriqués
les uns dans les autres.
Mais rechercher la simplicité, c'est aussi concevoir des
programmes modulaires. Ainsi que cela sera précisé
au chapitre 3.11, il conviendra,
par une méthode hiérarchique, de réaliser des unités de
programmes (des fonctions,...) de taille réduite (quelques dizaines
à quelques centaines de lignes au grand maximum) et dont la structure
devra être la plus simple possible (en évitant, par exemple,
les tests complexes imbriqués dans des itérations "sans fin", le tout
agrémenté d'intructions d'échappement du type goto).
Transposant le principe d'Occam dans le domaine
du génie logiciel,
il conviendra donc, dans la mesure du possible, d'écrire les
programmes les plus simples possibles et dont les fonctionnalites soient
en nombre réduit (si possible une seule). C'est de leur combinaison
que naitra la complexité ; leur coopération pourra être assurée en
utilisant des pipes ou bien des fichiers
temporaires. A titre d'exemple, ce
paysage
est obtenu, non pas à l'aide d'un programme unique et complexe,
mais grâce à l'utilisation conjointe de quatre outils fonctionnellement
très simples :
Une telle pratique peut et doit être généralisée. Elle a l'avantage
de la modularité et facilite grandement les évolutions ; ainsi,
dans l'exemple précédent, il est trivial de générer suivant les
mêmes principes, un paysage dont le champ de cote ne serait pas
fractal.
Il est même possible d'aller beaucoup plus loin. En effet le processus
décrit ci-dessus consiste simplement à enchainer l'appel de quatre
programmes. Dans certaines circonstances, la suite des opérations
pourra être beaucoup plus complexe (voir par exemple
La surface de la Lune), mais surtout il pourra
être nécessaire de la répéter plusieurs fois en faisant varier
un ou plusieurs paramètres (voir par exemple
Monument Valley dansante).
Il est alors envisageable de faire générer automatiquement, par un
programme spécifique, la suite des nombreuses commandes nécessaires ;
si celui-ci est écrit en C, il contiendra des
printf de génération des différents appels
avec la valeur des paramètres spécifiques. Un avantage supplémentaire
est de permettre des lois de variation des paramètres
a priori arbitraires et non prévues à
l'avance dans les programmes correspondant. C'est ainsi,
par exemple, qu'est générée cette visualisation
de la sensibilité aux conditions initiales.
[consulter l'exemple C]
[consulter l'exemple K]
3.11-Mettre en commun et favoriser la réutilisabilité :
Afin de limiter les efforts d'une part, et de faciliter
la mise au point et les évolutions d'autre part, il est essentiel
de mettre en commun, pour l'ensemble des logiciels réalisés,
les objets et structures communes. Cela concerne aussi bien
les constantes fondamentales (pi, e,...) que
les "opérations" utiles (produit scalaire, moyenne
géométrique,...).
Il serait faux de croire qu'une telle conception est très
répandue ; il n'en n'est rien : à titre d'exemple,
le source d'un système expert commercialisé est composé de
plusieurs dizaines de fichiers qui possèdent tous, de façon
dupliquée, un nombre très important de définitions strictement
identiques ! Inutile de dire que la mise à jour d'un tel
logiciel n'est pas sans risque...
Les très nombreux programmes tels qu'ils sont réalisés dans le
milieu de la recherche, par
exemple, à l'aide du Fortran, possèdent en général une
structure très "plate" :
-------- I
|-------- I
|-------- I
|-------- I
|-------- ...
|-------- ...
|-------- ...
P --------|-------- ...
|-------- ...
|-------- ...
|-------- ...
|-------- I
|-------- I
|-------- I
-------- I
où le programme P ne fait que référencer sur un seul niveau les primitives
I mises à la disposition du programmeur par le
langage de programmation utilisé. Bien sûr des fonctions et des sous-programmes
sont développés,
mais en général leur conception est telle, qu'elle ne favorise pas
leur réutilisabilité ; à titre
d'exemple, il est possible d'observer de façon très générale la
répétition, par l'utilisateur,
d'instructions ou de groupes d'instructions très voisines, voire
identiques. Nous proposons donc
une méthode aboutissant à une structure possédant beaucoup plus de niveaux et
dont les branches sont beaucoup plus equilibrées :
-------- I(1)
-------- I(2) -----|-------- ...
| -------- I(1)
-------- ... ... |-------- ...
| | -------- I(1)
| -------- I(2) -----|-------- ...
| -------- I(1)
-------- I(n-1) ---|-------- ...
| | -------- I(1)
| | -------- I(2) -----|-------- ...
| | | -------- I(1)
| -------- ... ... |-------- ...
| | -------- I(1)
| -------- I(2) -----|-------- ...
| -------- I(1)
P=I(n) ---|-------- ...
| -------- I(1)
| -------- I(2) -----|-------- ...
| | -------- I(1)
| -------- ... ... |-------- ...
| | | -------- I(1)
| | -------- I(2) -----|-------- ...
| | -------- I(1)
-------- I(n-1) ---|-------- ...
| -------- I(1)
| -------- I(2) -----|-------- ...
| | -------- I(1)
-------- ... ... |-------- ...
| -------- I(1)
-------- I(2) -----|-------- ...
-------- I(1)
I(1) désigne les primitives -les "atomes"- de plus bas niveau du langage
telles qu'elles sont mises à la disposition du programmeur, par exemple par le
langage K. Ensuite des instructions de
plus en plus complexes (au niveau des fonctionnalités, et non point
à celui de la syntaxe) I(i) -ou
"molécules"- sont élaborées avec un soucis constant de généralité et
de simplicité, pour arriver
finalement au niveau le plus élevé, où il est possible de qualifier
caricaturalement le programme P
d'instruction spécifique (puisque relative à un problème donné) I(n) de
plus haut niveau.
Ainsi, en respectant cette approche, la lecture du programme
pourra être réalisée à plusieurs
"échelles" ; plus celle-ci sera proche de la racine de l'arborescence évoquée
ci-dessus, plus la vue qu'en aura le
lecteur sera synthétique et plus la compréhension en sera globale et donc
meilleure. Les instructions
I(i) construites pour une application particulière seront placées
dans des bibliothèques (algèbre
linéaire, mécanique quantique, synthèse d'images,...)
afin d'être réutilisables ultérieurement ; ensuite, des utilitaires
en faciliteront l'accès et l'utilisation.
[consulter l'exemple C]
[consulter l'exemple K]
3.12-Du continu et des infinis :
Un ordinateur (quelqu'en soit le type) est une machine tout à la
fois finie (elle possède une capacité mémoire limitée) et
"discrète" (l'information doit être échantillonnée et quantifiée avant tout
traitement numérique). Ces deux caractéristiques sont malheureusement
trop souvent oubliées, ce qui peut être alors la source de
désagrements (euphémisme...).
A titre d'exemple du caractère fini
des ordinateurs, étudions la suite définie par :
S = 0
0
S = S + 1
n n-1
Quelle est donc la valeur de S(n) ? La réponse "mathématique"
est évidemment :
S = n
n
Malheureusement, la réponse "informatique" peut être très
différente, ainsi que le montre le programme suivant,
destiné à calculer numériquement cette valeur :
#define N "une certaine valeur positive et entière..."
main()
{
int n;
float sigma=0;
for (n=1 ; n <= N ; n++)
{
sigma = sigma + 1.0;
}
printf("\n somme calculee = %f",sigma);
}
Il donne systématiquement la valeur 16777216
(alors qu'il devrait évidemment donner la valeur N en l'absence de ce problème...)
pour toute valeur de N supérieure ou égale a 16777216.
En ce qui concerne le caractère discret
des ordinateurs, la conséquence immédiate est, en
toute généralité, l'impossibilité
de manipuler les nombres Réels. Ces derniers n'existent tout
simplement pas dans l'univers numérique. Ils sont très grossièrement
approchés par les nombres dits flottants.
A cause de cela, des constantes apparemment très simple (par exemple
4095.1 et 4096.1 qui seront utilisées ci-après)
ne sont pas représentables exactement en machine.
De plus, pour ces nombres, les opérations arithmétiques élémentaires
ne sont en général pas internes.
Lors de chacune d'entre-elles,
une erreur d'arrondi est introduite ;
celle-ci est alors la cause
de la perte de la propriété d'associativité
de l'addition et surtout
de celle de la multiplication (notons que dans ces conditions,
la multiplication n'est alors plus distributive par rapport a l'addition).
Ainsi, par exemple, en simple précision (c'est-à-dire sur 32 bits
et ce afin de simplifier l'impression hexa-décimale des résultats
-voir la même expérience en double précision 64 bits-),
le programme suivant (ou la fonction 'MUL' est destinée a forcer l'ordre
des multiplications) :
float MUL(x,y)
float x,y;
{
return(x*y);
}
main()
{
float A=3.1,B=2.3,C=1.5;
float X,Y;
X=MUL(MUL(A,B),C);
Y=MUL(A,MUL(B,C));
printf("\n (%f x %f) x %f = %f\n",A,B,C,X);
printf("\n %f x (%f x %f) = %f\n",A,B,C,Y);
}
donne comme résultats :
(3.10 * 2.30) * 1.50 = 10.695000 = 0x412B1EB8
#### #
et :
3.10 * (2.30 * 1.50) = 10.694999 = 0x412B1EB7
#### #
ce qui constitue deux résultats différents. On notera au passage,
que d'une part l'écriture de ce programme pourrait être bien plus
concise, mais que s'il est ainsi redigé, c'est
dans l'espoir de contrôler le plus possible le code généré par
le compilateur, aucune optimisation n'étant demandée
évidemment. D'autre part, l'expérience peut être refaite
avec d'autres langages de programmation (par exemple java
ou le programmeur maitrise l'ordre d'évaluation des opérations,
mais est-ce bien vrai ?) ;
elle produit évidemment le même phénomène...
L'expérience précédente pourrait être tentée à l'aide du programme :
main()
{
float A=3.1,B=2.3,C=1.5;
float X,Y;
X=(A*B)*C;
Y=A*(B*C);
printf("\n (%f x %f) x %f = %f",A,B,C,X);
printf("\n %f x (%f x %f) = %f",A,B,C,Y);
}
Lorsqu'elle ne montre pas le phénomène ici décrit,
deux explications, au moins, peuvent être avancées :
- le compilateur utilisé transforme la simple
simple précision 32 bits
en double précision 64 bits
à l'insu de l'utilisateur,
- le compilateur utilisé compile de façon similaire
les expressions (AxB)xC et Ax(BxC),
supposant la propriété d'associativité satisfaite.
Il conviendra alors d'augmenter le nombre de décimales
des trois variables 'A', 'B' et 'C', ou
encore d'essayer un autre compilateur, ou enfin
évidemment d'exploiter le programme utilisant la fonction 'MUL'...
Il convient de noter que la propriété d'associativité de l'addition
est donc, elle-aussi perdue, mais qu'elle se manifeste
plus rarement et de façon moins grave au niveau de ses consèquences.
Ainsi, par exemple, en simple précision (c'est-à-dire sur 32 bits
et ce afin de simplifier l'impression hexa-décimale des résultats
-voir la même expérience en double précision 64 bits-),
le programme suivant
(ou la fonction 'ADD' est destinée à forcer l'ordre des additions) :
float ADD(x,y)
float x,y;
{
return(x+y);
}
main()
{
float A=1.1,B=3.3,C=5.5;
float X,Y;
X=ADD(ADD(A,B),C);
Y=ADD(A,ADD(B,C));
printf("\n (%f x %f) x %f = %f\n",A,B,C,X);
printf("\n %f x (%f x %f) = %f\n",A,B,C,Y);
}
donne :
(1.10 + 3.30) + 5.50 = 9.900000 = 0x411E6666
# #
ce qui est différent de :
1.10 + (3.30 + 5.50) = 9.900001 = 0x411E6667
# #
[Evidemment une anomalie similaire peut-être constaté avec la distributivité de la multiplication sur l'addition]
Le seul aspect positif de ce "dysfonctionnement" est qu'il permet de
nous rappeler (montrer ?) qu'un ordinateur, sauf cas particuliers
(celui des petits nombres entiers, par exemple),
ne calcule pas exactement !
Lors de l'étude de problèmes non linéaires,
tout cela peut avoir des conséquences absolument
dévastatrices. A titre d'exemple, le calcul de l'itération
(dite Dynamique de Verhulst ) :
2
X = (R+1)X - RX
n n-1 n-1
qui est équivalent à la succession des calculs suivants :
X
0
|
---------------
| |
\|/ \|/
' '
2
X = (R+1)X - RX
1 0 0
|
---------------
| |
\|/ \|/
' '
2
X = (R+1)X - RX
2 1 1
|
---------------
| |
\|/ \|/
' '
2
X = (R+1)X - RX
3 2 2
|
---------------
| |
\|/ \|/
' '
etc...
peut se faire par le programme élémentaire, voire simpliste,
donné de façon très détaillée ci-après (sont mises en gras, les lignes
réellement essentielles) :
main()
{
double R=3.0;
double X=0.5;
int n;
for (n=0 ; n <= 80 ; n++)
{
if ((n%10) == 0)
{
printf("\n iteration(%04d) = %9.6f",n,X);
}
else
{
}
X = (R+1)*X - R*X*X;
}
}
donne sur une O200 Silicon Graphics (processeur R10000),
sous IRIX 6.5.5m et cc 7.2.1,
les résultats suivants en fonction des options d'optimisation :
O200 Silicon Graphics (R10000, IRIX 6.5.5m, cc 7.2.1) :
option '-O2' option '-O3'
X(00) = 0.500000 0.500000
X(10) = 0.384631 0.384631
X(20) = 0.418895 0.418895
X(30) = 0.046399 0.046399
X(40) = 0.320184 0.320192
X(50) = 0.063747 0.059988
X(60) = 0.271115 1.000531
X(70) = 1.328462 1.329692
X(80) = 0.817163 0.021952
Il est facile de calculer simultanément les extrema
de tous les résultats de tous les calculs intermédiaires
lors de chaque opération arithmétique élémentaire.
Le minimum trouve est strictement positif et inférieur à 1,
sans être petit par rapport au plus petit nombre positif représentable.
Le maximum, quant à lui, vaut quelques unités.
Il n'y a donc jamais de calculs élémentaires au cours desquels
seraient combinés deux nombres d'ordres de grandeur très
différents et donc incompatibles avec la représentation
flottante. Il convient de noter au passage que ce dernier problème peut
être lui-aussi la cause de phénomènes "ennuyeux" ; ainsi, à titre
d'exemple, en double précision 64 bits,
additionner 1 à 1e16 ("10 à la puissance 16") est sans effet,
ce qui conduit à l'égalité paradoxale suivante :
16 16
10 + 1 = 10
En ce qui concerne l'influence éventuelle de la syntaxe,
voici une expérience troublante effectuée (avec R = 3.0) en Java
(langage réputé pour son caractère
write once, run everywhere...) avec des variables
déclarées avec le type double :
un même programme calcule sur deux machines différentes
et de cinq façons différentes (mais équivalentes mathématiquement)
l'itération précédente. Voici les résultats obtenus :
O200 Silicon Graphics (processeur R10000, IRIX 6.5.5m, Java) :
(R+1)X-R(XX) (R+1)X-(RX)X ((R+1)-(RX))X RX+(1-(RX))X X+R(X-(XX))
X(0000) = 0.5 0.5 0.5 0.5 0.5
X(0500) = 1.288736212247168 0.007057813075738616 1.2767485100695732 1.246534177059494 0.03910723014701789
X(1000) = 1.3327294162589722 0.916560711983132 1.207710752523091 0.27770146115891703 0.26663342726567785
X(1500) = 1.1448646685382955 0.4481000759915065 0.3102077001456977 0.015374092695375374 0.9841637252962943
X(2000) = 1.0548628914440754 0.896126931497168 0.6851138190159249 0.009229885271816535 0.3860923315999224
X(2500) = 1.292802584458599 0.06063433547953646 1.174118726001978 0.6922411856638806 0.020878761210912034
X(3000) = 1.0497821908090537 0.0219606878364607 1.3287403237319588 0.11354602472378028 0.13270749449424302
X(3500) = 0.8115039383609847 1.3213031319440816 0.6545151597367076 0.5760786099237328 1.324039473116061
X(4000) = 0.04922223042798102 1.3203298564077224 0.09243804931690679 0.9496284087750142 1.316597313359563
X(4500) = 0.4745896653599724 0.32865616721789603 0.018965010461877246 0.25384661313701296 0.18512853535354462
PC (processeur Pentium II, LINUX Mandrake 7.0, Java) :
(R+1)X-R(XX) (R+1)X-(RX)X ((R+1)-(RX))X RX+(1-(RX))X X+R(X-(XX))
X(0000) = 0.5 0.5 0.5 0.5 0.5
X(0500) = 1.2887362122471679 0.00705781307573862 1.2767485100695732 1.2465341770675666 0.03910723014701789
X(1000) = 1.3327294162589722 0.91656071198313205 1.207710752523091 0.6676224369769922 0.26663342726567785
X(1500) = 1.1448646685382955 0.44810007599150647 0.31020770014569771 0.41049165176544455 0.98416372529629426
X(2000) = 1.0548628914440754 0.89612693149716804 0.68511381901592494 1.0026346845706315 0.3860923315999224
X(2500) = 1.3328681064703032 0.06063433547953646 1.1741187260019781 0.0154001182074282 0.02087876121091203
X(3000) = 1.2956769824648844 0.0219606878364607 1.3287403237319588 0.50504896336548377 0.13270749449424302
X(3500) = 0.19193027175727995 0.37986077053509781 0.6545151597367076 0.38299434265835819 1.324039473116061
X(4000) = 1.2491385720940165 0.96017143401896088 0.09243804931690679 0.6565274346305322 1.316597313359563
X(4500) = 0.00644889182443986 1.3185465795235045 0.01896501046187725 0.94966313327336349 0.18512853535354462
Ainsi, il n'est pas surprenant de constater que là-aussi
les cinq formulations donnent des résultats différents.
Par contre, il est étonnant d'observer que trois des
cinq d'entre-elles produisent, sur les deux machines,
des résultats incompatibles, les deux autres donnant des
valeurs identiques (ou presque...).
On notera pour la petite histoire qu'il est essentiel,
dans toutes ces expériences, de préciser
sans ambiguïté les versions du système et du compilateur utilisés,
au cas où ces résultats devraient être reproduits fidèlement.
Il est de plus essentiel de remarquer que d'une part, ce
calcul ne fait appel à aucune méthode numérique (d'intégration par exemple)
et que d'autre part,
ce programme ne peut être faux (ce qui peut être prouvé formellement)...
Moralité : ce programme ne calcule pas l'itération définie ci-dessus
dans le corps des nombres Réels. Oui, mais alors,
qu'étudie-t-il ? Sans que la réponse soit "n'importe quoi",
il faut bien dire que l'itération qui s'exécute effectivement
n'est pas "réellement" connue, car elle dépend d'un
trop grand nombre de paramètres non maitrisés,
voire inconnus...
Augmenter la précision des calculs (l'exemple précédent
utilise la double précision -64 bits-), ne fait
que retarder de quelque peu l'apparition du phénomène
ainsi que le montre cette expérience faite
avec bc
("arbitrary-precision arithmetic language"). Le tableau suivant
donne le numéro d'itération n
à partir duquel la partie entière est fausse,
en fonction du nombre de décimales utilisées pour
faire le calcul (paramètre scale) :
scale = 010 020 030 040 050 060 070 080 090 100
n = 035 070 105 136 169 199 230 265 303 335
Il est évident que le faible gain obtenu ne peut justifier
le coût de cette opération.
Ce qui précède ne doit pas faire croire
qu'un calcul mathématiquement linéaire soit
à l'abri de ces problèmes. Ainsi, examinons
le programme suivant
(sont mises en gras, les lignes réellement essentielles) :
main()
{
double B=4095.1;
double A=B+1;
double x=1;
int n;
printf("initialisation x = %.16f\n",x);
for (n=1 ; n <= 9 ; n++)
{
x = (A*x) - B;
printf("iteration %01d x = %+.16f\n",n,x);
}
}
Les valeurs calculées (après compilation sans optimisation par 'gcc' sur un PC Linux) sont les suivantes :
initialisation x = +1.0000000000000000
iteration 1 x = +1.0000000000004547
iteration 2 x = +1.0000000018630999
iteration 3 x = +1.0000076314440776
iteration 4 x = +1.0312591580864137
iteration 5 x = +129.0406374377594148
iteration 6 x = +524468.2550088063580915
iteration 7 x = +2148270324.2415719032287598
iteration 8 x = +8799530071030.8046875000000000
iteration 9 x = +36043755123945184.0000000000000000
Etant donné l'algorithme et, en particulier, qu'initialement :
x = 1
ainsi qu'il existe la relation suivante :
A-B = 1 (mathématiquement parlant...)
entre les constantes 'A'
(il est a noter qu'avec une partie décimale égale a 0.1, les parties entières posant
problème sont des puissances de 4 ; pour des parties décimales différentes,
la situation est plus complexe...)
et 'B',
la variable 'x' devrait être un invariant et donc rester égale a sa valeur initiale
(1) tout au long des itérations.
Or clairement,
il n'en n'est rien (et ce même apres un très petit nombre d'itérations...).
Au passage, ce programme donne la réponse attendue (1) si l'on remplace
double par float
(ce qui signifie alors tout simplement que, "par hasard",
A-B est égal exactement à 1) ou
encore si une compilation avec optimisation est demandée !
Evidemment ce phénomène est indépendant du langage de programmation utilisé
comme cela peut se voir avec les versions en C,
en Fortran 95
ou encore en Python de ce programme sans itérations (pour des raisons de simplicité).
Il conviendra donc de ne pas confondre la
linéarite mathématique et la
linéarite numérique ; cet exemple
est linéaire mathématiquement parlant mais non linéaire au
niveau du calcul numérique (une multiplication par une constante est présente,
or dans la mémoire d'un ordinateur il n'y a que peu de différences entre une
constante et une variable...) !
Au passage, ces constantes d'apparence simple ne sont pas
représentées exactement en machine :
B = 4095.1 = {0x40affe33,33333333}
A = B+1 = 4096.1 = {0x40b00019,9999999a}
A-B = 1.0000000000004547 = {0x3ff00000,00000800}
|
1.0 = {0x3ff00000,00000000}
(les valeurs précédentes ayant ete obtenues avec un PC).
Ce phénomène ne doit pas nous surprendre et est en fait
très commun : ainsi, par exemple, la
fraction 1/3 est une somme finie (un seul terme) d'inverses de puissances de 3
(1/3 s'écrit 0.1 en base 3),
alors que son écriture décimale (0.33333...) est une somme infinie
d'inverses de puissances de 10.
Notons au passage qu'évidemment (et malheureusement...) il n'existe pas de base de numération
grâce à laquelle tous les nombres auraient une représentation finie !
Les conséquences
de tout cela sont nombreuses ; en particulier,
les résultats numériques peuvent ainsi dépendre :
Ces difficultés se rencontreront a priori
avec tous les problèmes dits sensibles aux conditions initiales.
En effet, tout résultat intermédiaire dans un calcul peut être
considéré comme la condition initiale du calcul suivant. Une erreur d'arrondi
dans une valeur constitue donc une imprécision dans les conditions initiales du
calcul qui l'exploite. Mais elles pourront aussi se rencontrer avec des problèmes
linéaire comme cela fut montré précédemment...
La présence de plusieurs précisions différentes à l'intérieur d'un
même programme peut être, elle-aussi,
source de difficultés importantes.
Ainsi le problème dit excess precision problem, connu
des spécialistes du compilateur 'gcc', s'est manifesté
au début de l'année 2004 dans un nouveau programme en cours de développement,
sur pratiquement tous les PCs à processeur de type x86,
sous Linux et avec compilateur 'gcc'.
Il est apparu dans celui-ci qu'un test d'ordre
strict entre deux nombres égaux 'A' et 'B' était vrai et qu'ainsi simultanément
les deux conditions 'A = B' et 'A > B' étaient vérifiées, ce phénomène
n'apparaissant que lors de compilations optimisées (à partir de '-O2').
Les tests effectués et le rôle fondamental
de l'option '-ffloat-store' (inhibant le stockage des nombres flottants
64 bits dans les registres internes 80 bits) qui fait disparaitre le phénomène
ont valide le scénario suivant :
- Soient deux nombres flottants 'A' et 'B' calculés
égaux à l'instant présent et stockés dans les registres
flottants internes 80 bits de l'unité flottante (ou 'FPU')
du processeur (de type x86). Etant tous les deux
représentés par 80 bits significatifs, ils sont "compatibles"
entre-eux ce qui signifie que l'on peut les combiner arithmétiquement
et les comparer sans ambiguité (en particulier, ils
sont présentement égaux : A = B).
- Supposons qu'un manque de ressources flottantes contraigne
le transfert du nombre 'A' en mémoire. A partir de cet instant
'A' n'est plus représenté que par 64 bits significatifs.
Imaginons alors que l'optimiseur ignore ce déplacement
(et c'est bien le cas malheureusement...).
- Supposons ensuite que quelques instructions plus tard
la comparaison de 'A' (stocké sur 64 bits en mémoire)
et de 'B' (toujours stocké sur 80 bits dans un registre) soit
demandée. Le nombre 'A' est donc retransféré dans un
registre 80 bits et donc 80-64=26 bits (nuls très certainement) lui
sont ajoutés. A partir de cet instant-là les deux
nombres 'A' et 'B' ne sont plus "compatibles"
(sauf le cas exceptionnel ou les 26 derniers bits de 'B'
sont nuls) et leur comparaison n'a plus de sens...
- Il est possible d'imaginer de nombreuses autres circonstances
où de telles difficultés apparaissent et par exemple le
cas de deux nombres 'A' et 'B' différents dans une représentation
80 bits et qui deviennent égaux en 64 bits...
Au mois de mars 2004, ce phénomène peut être observé à l'aide du programme suivant :
double f(x)
double x;
{
return(1/x);
}
main()
{
double x1=f(3.0);
volatile double x2;
x2=x1;
if (x2 != x1)
{
printf("DIFFERENT\n");
}
}
Ce programme, lorsqu'il est compilé avec 'gcc -O3 -fno-inline' sur un PC Linux
avec un processeur Intel x86, imprime le message "DIFFERENT" !
Il convient de noter qu'au mois de mars 2018, ce programme fonctionne correctement...
Il convient de noter au passage que la seule solution universelle à ce problème
(c'est-à-dire qui soit à la fois indépendante des architectures matérielles,
des constructeurs, des options de compilation,...) semble être, par
exemple dans le cas des tests, de remplacer ces derniers par des appels
à des fonctions (ceci est trivial à implémenter lors de l'utilisation
du langage K), forçant ainsi
le passage par la pile et donc par une
représentation sur 64 bits. Ainsi le test :
A > B
ou 'A' et 'B' désignent deux nombres flottants 64 bits, pourra s'écrire :
fIFGT(A,B)
la fonction 'fIFGT(...)' étant définie par :
unsigned int fIFGT(x,y)
double x;
double y;
{
return(x > y);
}
et en faisant en sorte qu'elle ne puisse être développée (inlined)
par le compilateur (option '-fno-inline' de 'gcc')...
Ainsi, augmenter la précision et/ou utiliser simultanément plusieurs
précisions différentes peut entrainer des difficultés et des complications imprevues...
Le passage par des fonctions pour exécuter les instructions élémentaires
(addition, soustraction, multiplication, division, tests,...)
d'un programme, même s'il est parfois pénalisant au niveau des
performances, possède un autre avantage :
il permet d'imposer l'ordre d'exécution de celles-ci,
alors qu'ainsi que cela fut montre précédemment, celui-ci peut influer
de façon plus ou moins importante, voire dramatique,
sur les résultats obtenus.
En effet, en toute généralité :
F.G # G.F
'F' et 'G' désignant deux fonctions arbitraires ; les compilateurs
n'ont donc absolument pas le droit de remplacer 'F(G(...))' par 'G(F(...))'.
Ainsi, par exemple,
ces deux images et ,
calculées à l'aide d'un processus non itératif
sur la même machine (un PC sous Linux), avec et sans imposer l'ordre respectivement,
semblent identiques,
alors qu'elles sont en réalité légèrement différentes.
Au passage, l'utilisation de fonctions présente un autre avantage :
celui de permettre l'extension de l'arithmétique.
Ainsi, ces problèmes peuvent se manifester même en dehors des systèmes
itératifs. Dans ces derniers les erreurs d'arrondis se trouvent amplifiées
numériquement lors des calculs successifs, alors que dans les cas illustrés par
l'exemple précédent, l'amplification peut être d'une autre nature.
Ici, elle peut être qualifiée de "visuelle" et s'analyse de la façon
suivante : imaginons deux points P1(x1,y1,z1) et P2(x2,y2,z2) mathématiquement identiques
(x1=x2, y1=y2 et z1=z2) et ne différant que par leur couleur (rouge pour P1 et vert
pour P2). Supposons l'axe 'OZ' orthogonal au plan de l'image et orienté vers l'observateur.
Puis imaginons que la compilation
du programme ait lieu sur deux machines identiques, à la version de leur compilateur près.
Nous pourrons obtenir (et c'est le phénomène rencontré dans l'exemple précédent !)
{x1=x2, y1=y2 et z1>z2} sur la première (d'ou un point rouge -P1 est devant P2-)
et {x2=x1, y2=y1 et z2>z1} sur la seconde (d'ou un point vert -P2 est devant P1-)...
Enfin, utiliser l'arithmétique dite
d'intervalle, semble
aussi voué à l'échec sur les problèmes sensibles aux conditions initiales
(comme l'est l'exemple précédent). En effet, un intervalle
[x-epsilon,x+epsilon] encadrant une valeur exacte ne peut,
en toute généralité, qu'"exploser" au cours
des calculs suivants,
puisque 'x-epsilon' et 'x+epsilon' peuvent être considérées
comme deux conditions initiales très voisines l'une de l'autre...
Le problème des N-corps donne
un exemple concret de ces difficultés.
Alors, ne pourrait-on pas se passer des Nombres Réels pour faire de la physique mathématique ?
3.13-Un exemple C et un exemple K :
Enfin, donnons un exemple C
d'un programme dans lequel tous les conseils précédents semblent
être respectés. Il s'agit
de la résolution d'un problème d'arithmétique.
Un exemple K est aussi consultable.
3.14-Exploiter les disparités :
Indépendamment du problème des erreurs dont le programmeur est
responsable, les exemples précédents ont montré que d'une part
le calcul flottant n'était pas sûr
et que d'autre part
les systèmes utilisés n'étaient pas d'une fiabilité logicielle absolue.
Dans ces conditions, comment garantir la qualité des résultats
produits ? Malheureusement, cette question reste bien souvent
sans réponse.
Malgré tout, un conseil peut être donné : compiler
et exécuter un même programme sur plusieurs systèmes différents
(différents signifiant que les constructeurs doivent
l'être, de même que les compilateurs, les librairies
utilisées,...), puis comparer les résultats ainsi obtenus.
Si des différences significatives apparaissent, elles signifient que
les résultats obtenus ne sont pas fiables ; il faut ensuite évidemment
comprendre l'origine du problème. Plusieurs causes non exclusives l'une
de l'autre sont possibles :
- une erreur du programmeur impliquant un comportement
différent d'un système à l'autre (par exemple,
une variable non initialisée, ce que les
compilateurs ne traitent pas tous de la même façon),
- une erreur de compilation (revoir les
quelques exemples antérieurs)
donnant un exécutable incorrect,
- une erreur dans l'une des librairies utilisées
(revoir l'exemple
de la fonction 'pow(...)'),
- une sensibilité du problème traité aux
erreurs d'arrondi,
- ...
la liste précédente n'étant pas exhaustive, bien entendu...
Si par contre les résultats sont identiques, il est alors
malheureusement impossible de conclure ! La seule certitude est
que les systèmes utilisés se comportent de la même façon dans ce
contexte particulier (il peuvent par exemple tous provoquer la même anomalie).
En ce qui concerne la logique du programme, il est plus
difficile de la mettre ainsi à l'épreuve, sauf cas particulier
(comme le problème d'initialisation évoqué ci-dessus).
3.15-Gérer les mises à jour :
La mise à jour d'un programme est a priori
une opération simple, effectuée à l'aide d'un éditeur de textes
(vi par exemple). Malgré cela, il convient
de noter qu'elle n'est pas sans danger.
D'une part, il
est plus que facile d'introduire quelque chose dont les conséquences
n'ont pas été complétement prévues (il convient de noter qu'une
prévision absolue est en général impossible) ; c'est pourquoi,
il est recommandé d'archiver les versions opérationnelles d'un
programme associées à la date, ainsi qu'à des jeux de tests éventuels.
D'autre part, la destruction accidentelle de caractères
(ou encore de lignes) menace à tout moment le programmeur lors de l'édition
d'un programme ;
dans de nombreux cas, cela aménera à des fautes de syntaxe signalées
par les compilateurs (par exemple lorsqu'une parenthèse est supprimée),
mais malheureusement dans d'autres circonstances cela pourra passer inaperçu (ainsi
le cas où un caractère est détruit dans une constante ou bien
dans le nom d'une variable scalaire définie
implicitement dans un programme Fortran, donnant ainsi
naissance à une nouvelle variable ne faisant pas partie de la logique du problème...).
Pour se prémunir facilement de ces fausses
manipulations, une solution consiste à mémoriser le source d'un
programme avant sa modification, puis après sa mise à jour,
à éditer les différences entre les versions avant
et après (la commande diff
permet de réaliser cela de façon triviale).
[Plus d'informations]
Copyright © Jean-François COLONNA, 1997-2024.
Copyright © France Telecom R&D and CMAP (Centre de Mathématiques APpliquées) UMR CNRS 7641 / École polytechnique, Institut Polytechnique de Paris, 1997-2024.