Méthodes de génie logiciel avec Ada/Première partie

Méthodes et langages ont toujours eu des rapports conflictuels. Voici un rapprochement de quelques citations et/ou lieux communs pour comprendre l'étendue du problème.

Langages et méthodes
Le langage n'a aucune importance ; seule l'application de bonnes méthodes peut résoudre la crise du logiciel.
Le langage Ada a été spécifiquement conçu pour soutenir les méthodes de conception.
La méthode doit être indépendante du langage d'implémentation utilisé.
La méthode HOOD a été conçue pour répondre à un appel d'offres de l'Agence spatiale européenne qui voulait une méthode spécifique pour Ada.
Langage sans méthode n'est que ruine... de la société de service.

Nous voyons, à travers ces quelques exemples, une contradiction dans la perception du rôle du langage par rapport à celui de la méthode : simple moyen de traduire une conception en une forme compréhensible par l'ordinateur pour les uns, expression directe et vérifiée de la conception pour les autres. En fait, les deux ont raison... selon le langage considéré. Dans cette partie, nous allons étudier les rapports des méthodes aux langages, et nous verrons que si les autres langages ne peuvent que partiellement apporter leur soutien aux méthodes, Ada a été conçu précisément pour leur servir de prolongement naturel.

Rôle et principes des méthodes de conception

modifier

Si la nécessité d'une méthode est bien établie dans le développement de logiciels industriels, elle est trop souvent ressentie comme une gêne par le programmeur, qui souhaiterait toujours coder immédiatement. D'ailleurs, celui-ci a souvent écrit des programmes sans utiliser de méthode, et ils ont très bien marché. L'intérêt d'une méthode ne se fait sentir qu'à long terme, souvent au niveau des phases d'intégration. Ceux qui pensent pouvoir s'en dispenser nous rappellent cette citation d'un industriel américain du début du siècle :

 Nous n'avons que faire du téléphone ; nous possédons un système de coursiers qui fonctionne très bien.

Aussi est-il important de bien comprendre le rôle joué par les méthodes pour la conception et leur impact sur le cycle de vie. La décision d'écrire un programme d'informatique est toujours prise dans le but de résoudre un problème du monde réel[1]. L'expression de la solution informatique souhaitée est le cahier des charges, qui définit ce que doit faire l'application informatique. Nous allons parler ici des méthodes permettant, à partir de là, d'obtenir un programme répondant à ses exigences ; il s'agit donc de ce que l'on appelle les méthodes de conception détaillée, par opposition aux méthodes de spécification qui interviennent dans l'établissement du cahier des charges.

  1. Sauf pour ceux qui écrivent des virus, qui cherchent alors plutôt à créer des problèmes…

Complexité et limitations de l'esprit humain

modifier

Lorsque le problème à résoudre est suffisamment simple, il est relativement facile de concevoir directement le programme à partir du cahier des charges. Dans l'enseignement traditionnel de l'informatique, qui se réduit trop souvent à l'enseignement de la programmation, on fait exprès de donner des problèmes dont la solution se déduit relativement facilement de l'énoncé. Même si l'on impose une méthode à l'étudiant, celui-ci a généralement l'impression qu'elle est inutile, car il se sent capable de produire directement la solution. Bien souvent, il écrira tout de suite le programme et ne produira les documents de conception que par la suite, pour faire plaisir au professeur.

Ne jetons pas la pierre aux seuls étudiants : deux ans plus tard, ceux-ci sont ingénieurs et continuent à appliquer les mêmes méthodes de travail. Le problème est que cela fonctionne parfaitement ! Bien sûr on connaît parfois quelques difficultés de maintenance, la survie du programme est mise en péril lors du départ du concepteur initial, mais grosso modo on y arrive, d'autant mieux que l'entreprise dispose de personnels « doués ». Mais insensiblement, la taille des programmes tend à augmenter. On continue à développer de la même façon, avec des difficultés croissantes, mais puisqu'on y arrivait jusqu'à présent, il n'y a pas de raison que cela ne continue pas à fonctionner… Le problème est qu'il existe une taille critique, que l'on peut évaluer aux alentours de 10 000 lignes de code par programmeur[1], pour laquelle les problèmes de gestion vont soudain déborder les capacités des programmeurs. Un proverbe résume bien ce phénomène :

 On ne construit pas un pont sur un estuaire en extrapolant une passerelle sur une rivière.

On ne peut définir précisément à partir de quelle largeur de rivière il faudra changer de technologie ; mais il est certain que personne ne songerait à faire le pont de Normandie au moyen d'un tronc jeté en travers ! En logiciel, le problème est qu'en quelque sorte, la rivière s'élargit progressivement jusqu'au point de rupture : que survienne une demande nouvelle, le besoin d'intégrer des interfaces graphiques, le portage sur une nouvelle machine, et la taille critique sera dépassée. Tout à coup, le programmeur sera incapable de répondre à la demande, il sera dépassé par son programme. Que s'est-il passé ?

On a découvert [Mil56][2] que le cerveau humain ne peut s'occuper en moyenne que de 7 ± 2 éléments en même temps ; tant que le programmeur reste au dessous de cette limite, tout va bien. La complexité du logiciel augmentant, elle va atteindre insensiblement la limite des capacités du concepteur. Celui-ci ne parviendra plus à maîtriser simultanément tous les éléments nécessaires. L'effet de ce phénomène est particulièrement frustrant, car le programmeur continue à maîtriser les vues partielles de son problème ; simplement, lorsqu'il commence à saisir une partie des éléments nécessaires à la compréhension, cela se fait au détriment d'une autre partie qu'il avait comprise précédemment. Face à cette situation, il va chercher désespérément à continuer à percevoir tous les éléments. S'il est très doué, il y parviendra temporairement. Mais une fois le problème résolu et l'échauffement intellectuel passé, ni lui, ni aucun autre programmeur chargé d'effectuer la maintenance ne retrouvera cet «état de grâce» temporaire : le programme sera devenu totalement incompréhensible.

Il faut se faire une raison : la seule chose que la technologie ne peut améliorer, ce sont nos facultés mentales. Comme le disait Dijkstra : «I only have a very small head and I must live with it»[3]. Méthodes et langages sont les outils qui nous permettent de réaliser des logiciels toujours plus complexes, en tenant compte de nos limitations intellectuelles.

  1. Cette limite ne provient pas de mesures précises, mais est plutôt déterminée intuitivement par nos expériences personnelles. Nous pensons cependant que la plupart des chefs de projet seront d'accord avec ce chiffre.
  2. G. A. Miller. «The Magical Number Seven, Plus or Minus Two», The Psychological Review, vol.63, n° 2, mars 1956.
  3. Je n'ai qu'une toute petite tête, et il faut bien que je vive avec.

Notion de saut sémantique

modifier

Le cahier des charges d'un problème informatique représente une description de haut niveau de la solution souhaitée. Un programme est une réalisation particulière du cahier des charges, c'est-à-dire une description de bas niveau d'une solution. La conception, c'est-à-dire le processus permettant de passer d'une description à l'autre, est donc fondamentalement un saut sémantique.

Dans un problème simple, un tel saut peut s'accomplir directement : des méthodes intermédiaires sont donc inutiles. Au fur et à mesure que le problème se complique, la taille du saut sémantique augmente. Rapidement, les limitations intrinsèques de nos capacités intellectuelles ne nous permettent plus de maîtriser ce saut, et il ne devient plus possible de passer directement de l'énoncé du problème à sa solution. Or que fait-on dans la vie courante lorsque l'on est en présence d'un saut trop important pour pouvoir être effectué directement ? On le remplace par une succession de petits sauts (communément appelée escalier), individuellement faisables, et dont la somme permet d'accomplir la descente souhaitée. De même en génie logiciel, il faudra définir des étapes de conception intermédiaires destinées à abaisser graduellement le niveau sémantique depuis le niveau du cahier des charges jusqu'à celui du langage de programmation.

Surmonter la complexité

modifier

Comment faire pour réaliser ces différents «sauts» qui constituent l'essentiel d'une méthode de conception ? Le problème, qui n'est pas propre à l'informatique, a été résolu par des moyens connus depuis Descartes :

«

... diviser chacune des difficultés que j'examinerais en autant de parcelles qu'il se pourrait et qu'il serait requis pour les mieux résoudre.

»
— R. Descartes. Discours de la méthode. Leyde, 1637. [Des37]


Informatiquement parlant, ceci signifie que l'on divisera un programme en modules, qui seront des unités de programme pouvant être considérées individuellement par le programmeur. Dans le reste de ce chapitre, nous utiliserons ce terme de «module» pour désigner l'unité de structuration conceptuelle, même si cela ne correspond pas forcément à la notion informatique de «quantité compilable individuellement».

Ce que Descartes ne dit pas, c'est le nombre requis de telles divisions (autant qu'il serait requis… voilà qui ne nous avance guère !). On pourrait croire qu'il suffit de diviser le problème en un grand nombre de modules pour surmonter la complexité. Or il n'en est rien ; le fait de diviser un programme introduit un niveau de complexité supplémentaire : celui dû aux relations nécessaires entre les parties. Supposons une échelle arbitraire, où 7 représenterait la limite de ce qui peut être raisonnablement fait par une seule personne ; si un programme est de complexité 14, il faudra non pas deux, mais trois personnes pour le résoudre, la troisième étant chargée de gérer la communication entre les deux autres. Ceci va en fait limiter le gain pouvant être obtenu de la décomposition. Considérons en effet deux cas extrêmes :

  • Nombre de modules = 1. Aucune complexité de relation, mais complexité interne au module maximale.
  • Nombre de modules = autant que d'instructions (chaque module ne contient qu'une seule instruction). Aucune complexité interne, mais énorme complexité de relation. En fait, la complexité globale dans ce cas est équivalente au cas précédent ; on n'a fait que transformer une complexité interne en complexité de relation.
 
Figure 5 : Variation de la complexité avec le nombre de modules
Figure 5 : Variation de la complexité avec le nombre de modules

Les deux cas extrêmes étant équivalents, cela signifie qu'il existe une taille optimale de décomposition, pour laquelle la complexité globale (interne + relation) est minimale[1], comme illustré sur la figure 5. En revanche, rien n'indique que cette complexité minimale soit inférieure à 7 ; autrement dit, dans un projet d'une taille suffisante, si l'on se contente de le diviser simplement en modules, on atteindra une complexité de relation trop importante avant d'avoir obtenu une complexité interne acceptable. En quelque sorte, décomposer un problème en sous-problèmes ne nous fournit qu'une seule «marche» dans notre descente des niveaux sémantiques. Il faut raffiner le processus afin d'obtenir autant d'étapes que nécessaire.

Pour cela, on décomposera le problème en un nombre de modules restreint ; la complexité de relation sera acceptable, mais chaque module possédera une complexité interne trop importante. On considérera alors chaque module comme un problème autonome, et on lui appliquera de nouveau la méthode ci-dessus, définissant ainsi des sous-modules, puis des sous-sous-modules jusqu'à obtenir des unités de taille acceptable. Ceci implique, pour garder une complexité de relation gérable, qu'un module d'un certain niveau ne doit avoir de relation conceptuelle qu'avec les modules de même niveau. Le programme sera donc organisé en couches logicielles hiérarchisées, dont l'organisation générale correspondra à la figure 6. Chaque module dépendra logiquement d'un nombre restreint d'autres modules : le graphe de ces dépendances s'appelle la topologie de programme.

 
Figure 6 : Décomposition hiérarchique
Figure 6 : Décomposition hiérarchique

Ces considérations ne sont que la justification en termes de maîtrise de la complexité d'une technique connue depuis longtemps : la méthode d'analyse descendante (ou de décomposition hiérarchique). Il reste pourtant une question en suspens dans ce mode de décomposition : en fonction de quels critères va-t-on décomposer les difficultés en parcelles (ou, disons, les programmes en modules) ? Un problème, quel qu'il soit, mais en particulier en informatique, ne se découpe pas en tranches comme un vulgaire saucisson : il faut établir des critères logiques pour répartir les différentes parties du logiciel entre les modules. L'analyse précédente montre qu'il existe deux dimensions dans l'analyse : la décomposition horizontale qui détermine les sous-modules d'un module donné, et la décomposition verticale qui détermine à quel sous-niveau un module doit appartenir. Il sera donc nécessaire de posséder deux ensembles de critères : l'un, pour la décomposition horizontale, qui détermine la répartition des éléments entre modules d'une même étape de décomposition, et l'autre, pour la décomposition verticale, qui détermine à quel niveau de la décomposition un élément donné doit apparaître.

  1. Mathématiquement parlant, cela pourrait aussi bien être un maximum... fort heureusement, il n'en est rien.

Caractérisation des méthodes

modifier

Il existe de nombreuses méthodes de conception, regroupées dans un certain nombre de familles (méthodes structurées, en flots de données, entités-relations, orientées objet...). Chaque méthode a ses partisans, et selon le domaine du problème à résoudre, l'une ou l'autre peut s'avérer plus performante. Comment donc définir ce que contient une méthode ?

L'élément essentiel qui caractérise une méthode est son principe de base, c'est-à-dire les critères utilisés pour les décompositions horizontale et verticale. On dit que des méthodes appartiennent à la même famille lorsque leurs critères de décomposition sont identiques, ou au moins très voisins. Noter que comme la décomposition résulte de l'application de ces critères, la topologie de programme obtenue sera, en général, caractéristique de la méthode.

Ensuite, il convient de mettre en œuvre ces principes : les méthodes définissent donc une démarche précise à suivre pour guider pas à pas le processus de conception. En particulier, elles définissent des étapes de conception et des critères d'acceptabilité permettant de passer d'une étape à la suivante.

Enfin, une méthode définira les représentations (textuelle, graphique) et les documents qui doivent accompagner la conception, dans un but de suivi du développement aussi bien que d'aide à la maintenance.

En général, des outils informatiques ont été développés pour soutenir la bonne application des méthodes, en particulier en automatisant le suivi de la conception et la production des documents associés.

Méthodes de conception et langages

modifier

Comme nous l'avons vu précédemment, il est nécessaire d'abaisser graduellement le niveau sémantique de la description de la solution jusqu'au point où l'on peut la décrire au moyen du langage de programmation. La comparaison avec l'escalier montre bien comment définir de façon optimale le nombre et le niveau de ces étapes :

  • Il y aura d'autant plus d'étapes intermédiaires que la distance entre le niveau du problème et celui du langage sera plus grande.
  • Les étapes seront optimales si elles correspondent à des sauts sémantiques d'égale hauteur.

Ces constatations, qui semblent évidentes, ont pourtant des conséquences qui ne sont pas toujours comprises. En particulier, on a longtemps dit que les méthodes de conception devaient être indépendantes du langage de codage utilisé. Si l'on utilise un langage de plus haut niveau, la répartition optimale des «marches» sera différente, certaines pouvant même être appelées à disparaître (Figure 7). C'est ainsi que s'il est fondamental de décrire des algorithmes au moyen de pseudo-code si l'on programme en FORTRAN, c'est totalement inutile lorsque l'on utilise Ada, car le langage de codage est alors de même niveau sémantique (si ce n'est supérieur !) que le pseudo-code.

 
Figure 7 : Niveau relatif des méthodes et des langages
Figure 7 : Niveau relatif des méthodes et des langages

Retenons donc que l'utilisation d'un langage de haut niveau comme Ada va permettre une remontée du niveau sémantique de toutes les étapes intermédiaires. C'est donc tout le processus de développement qui est remis en cause.

Ne peut-on aller plus loin, inventer un langage de niveau si haut qu'il nous dispenserait totalement de l'utilisation d'une méthode ? En un sens oui : à partir d'une méthode comme HOOD, il existe des outils qui produisent (presque) automatiquement le code Ada correspondant. Dans l'idéal, il n'existerait plus d'intervention manuelle entre la méthode et l'exécution par l'ordinateur ; c'est la méthode qui serait devenue langage de très haut niveau.

En pratique, il existe une limite à la remontée de niveau sémantique des langages de programmation conventionnels. Ceux-ci se veulent en effet «universels», donc permettant de réaliser des applications de domaines, et de contraintes, très variés. Or contrôler, c'est nécessairement restreindre. Certaines possibilités offertes par un langage sont impératives pour un domaine d'application, et dangereuses pour d'autres : on n'utilisera pas l'héritage dans des applications temps réel, ni la manipulation directe d'interruptions dans une application de gestion. Notons au passage qu'il est par conséquent normal qu'aucune application n'utilise l'intégralité des possibilités d'un langage.

Tout langage est donc un compromis entre puissance d'expression (pouvoir tout faire) et sécurité (tout contrôler). Un langage à vocation universelle sera limité dans ses possibilités de contrôle par la nécessité d'offrir les services requis par diverses classes d'applications. La méthode en revanche est généralement spécifique d'un domaine particulier : personne ne songerait à utiliser Merise en temps réel, ni Buhr en gestion ! Étant plus spécifique, elle peut donc exprimer des contraintes supplémentaires.

         En plus de sa fonction d'orientation et de guidage du processus de développement,
         le rôle d'une méthode est d'imposer des restrictions supplémentaires, au-delà de
         ce qui peut être vérifié au niveau du langage de programmation.

Rôle et principes d'un langage de programmation

modifier

Après avoir étudié le rôle des méthodes, nous allons nous intéresser dans ce chapitre à leur prolongement, le langage, et à son influence sur la conception et sur la maintenabilité des applications informatiques. Dans cette réflexion sur le rôle des langages de programmation, nous utiliserons et nous contrasterons principalement Ada d'une part, et le couple C/C++ d'autre part. Ce choix n'est pas seulement dû au fait que ces langages sont les plus utilisés actuellement: nous verrons qu'ils correspondent à deux approches, deux philosophies pourrait-on dire, de la programmation fondamentalement différentes, qui les ont conduit à faire des choix radicalement opposés. Pourquoi ne pas faire la différence entre C et C++? C++ est un perfectionnement technologique du langage C ; on l'appelle souvent the C after[1]. Mais malgré l'introduction d'outils de plus haut niveau (notamment l'héritage), les principes, les rapports du programmeur avec son langage, sont restés les mêmes.

  1. Le C d'après.

Le double niveau de lecture

modifier

On considère généralement que le rôle d'un langage est de porter une information, un message, d'une personne qui parle à une personne qui écoute. Dans le cas des langages de programmation, celui qui «parle» est bien entendu le programmeur. Mais qui est celui qui «écoute»? L'ordinateur, pense-t-on généralement. Certes, mais aussi celui qui relira le logiciel plus tard, c'est-à-dire le programmeur de maintenance[1].

Hofstadter [Hof85] soutient que le message ne porte pas l'information, mais ne fait que déclencher l'information contenue dans l'interlocuteur en «appuyant» sur des «boutons» préexistants. Considérons par exemple la phrase suivante (tirée d'un livre de lecture, classe de CM1):

Dans la boulangerie, Madame Durand dit à Florence: «Tu n'as pas vu Minet?»

En lisant cette phrase, vous avez parfaitement compris que

La boulangère s'inquiète auprès de la petite fille d'avoir perdu son chat.

Or aucun des éléments de la seconde formulation ne figure dans la première! Comment savez-vous que Madame Durand est la boulangère? Parce que vous savez que si ce n'était pas la boulangère, la formulation «dans la boulangerie» n'aurait aucune utilité. Qui vous a dit que Florence était une petite fille? Parce que vous savez que la formulation «Madame X» d'un côté, un prénom de l'autre traduit une relation d'adulte à enfant. Qui vous a dit que le chat était perdu? Parce que vous savez que la construction «Tu n'as pas vu...» traduit une inquiétude. Et bien entendu, vous savez que «Minet» est un nom de chat... Cet exemple montre bien que la compréhension par l'homme d'un texte fait usage de toute une culture extérieure au seul message. Or la lecture d'un texte de programme par un ordinateur ne mettra en jeu que le texte lui-même et les règles du langage. C'est de cette différence de lecture que provient un grand nombre d'erreurs, la plus célèbre étant celle survenue à la NASA dans les années 60. Une sonde destinée à observer Vénus est passée à dix fois la distance prévue, suite à une erreur de calcul dans la trajectoire. Une commission d'enquête a révélé qu'un programmeur avait écrit (en FORTRAN) :

DO 10 I = 1.5

Dans l'esprit de celui qui avait écrit cette instruction, ainsi que dans l'esprit de tous ceux qui l'avaient relue par la suite, il s'agissait d'une boucle DO. Cette conviction était suffisamment forte pour que l'on ne remarque pas la présence d'un point à la place de la virgule normalement requise dans une boucle DO... Le compilateur qui ne comprend en revanche que ce qui est écrit a relu cette instruction sous la forme (sachant que les espaces ne sont pas significatifs en FORTRAN) :

DO10I = 1.5

c'est-à-dire comme l'affectation de la valeur réelle 1.5 à la variable de nom DO10I, non déclarée (mais ce n'est pas un problème en FORTRAN où la déclaration de variable n'est pas obligatoire[2])...

Plus prosaïquement, si vous lisez dans un programme:

for I in 1..MILLE loop
	...
end loop;

vous serez persuadé que la boucle sera exécutée 1000 fois, car vous savez que la chaîne de caractères 'M', 'I', 'L', 'L', 'E' a la même signification que le nombre 1000. Le compilateur, lui, ira regarder dans sa table des symboles, où il trouvera que l'identificateur MILLE résulte de la déclaration :

MILLE : constant := 10_000;

il exécutera donc la boucle dix mille fois...[3]

La compréhension par le programmeur de son programme s'appuie donc sur des éléments non écrits faisant partie de sa culture propre. Pourquoi est-ce nécessaire? Après tout, le compilateur est bien capable de s'y retrouver sans ces éléments «culturels». Mais l'ordinateur dispose d'un atout complémentaire : une mémoire illimitée[4] ; toute information qu'on lui a communiquée une fois reste disponible. Au contraire, le programmeur ne dispose que d'une mémoire à court terme limitée (dont, comme nous l'avons vu, on évalue la capacité à sept «cases»). L'homme va compenser ce manque de mémoire en déduisant les éléments dont il a besoin, recréant ainsi l'information pour éviter de la stocker. Plus prosaïquement, ceci justifie la nécessité (toujours répétée, jamais justifiée, et pas toujours observée) d'avoir des identificateurs parlants : si l'on veut stocker le nombre de lignes imprimées, cela ne fait aucune différence pour l'ordinateur d'appeler la variable XYZ123, NBLN ou NOMBRE_DE_LIGNES. En tant qu'être humain, je dois entièrement mémoriser le rôle de la variable XYZ123. Dans le deuxième cas, je dois mémoriser que NB est une abréviation pour «nombre» et LN pour «lignes». Ce stockage est cependant plus économique, car de nombreuses variables peuvent être construites en combinant ainsi un petit nombre d'abréviations. Avec la troisième forme, je n'ai rien à mémoriser : je peux déduire entièrement la signification de la variable de son nom.

Cependant, cette information portée par le nom de la variable échappe totalement au compilateur : rien n'empêche la variable NOMBRE_DE_LIGNES de désigner la distance de la Terre à la Lune ! Tout l'art du programmeur va donc être d'écrire son programme de façon qu'il porte deux messages à la fois : un pour le compilateur, gouverné par les seules règles du langage, et un pour le lecteur, gouverné par son fonds culturel, et de faire en sorte que les deux correspondent ! La difficulté réside bien entendu en ce que le « fonds culturel » varie d'un programmeur à l'autre. Un exemple typique se trouve dans l'utilisation d'abréviations : par exemple, LN est une abréviation « évidente » de « ligne » pour certains, alors que pour d'autres elle signifiera « longueur » ou « logarithme népérien ». Il convient donc d'éviter de façon générale l'usage de tout « fonds culturel » non explicite, et plus particulièrement des abréviations[5].

Un exemple caractéristique de la non-compréhension de ce double rôle du langage de programmation peut être trouvé dans les langages C et C++ : dans ceux-ci, deux identificateurs qui ne diffèrent que par l'usage des minuscules ou des majuscules sont différents. Ainsi, Nombre_de_Lignes ne désigne pas la même variable que NOMBRE_DE_LIGNES. Du point de vue du compilateur, il s'agit de deux chaînes de caractères différentes, donc de deux identificateurs différents. En revanche, la signification « culturelle » pour l'être humain ne dépend pas de la façon d'écrire les mots, donc la compréhension de la signification est la même. On oblige le programmeur C à raisonner avec la vue de l'ordinateur, donc à mémoriser un nombre d'informations plus grand pour comprendre la signification d'un programme.

  1. Dans le cadre du génie logiciel, on doit toujours considérer que celui qui relit un logiciel n'est pas celui qui l'a écrit.
  2. C souffre du même problème : il existe de nombreux cas où une faute de frappe d'un seul caractère conduit à un programme légal, mais faisant quelque chose de totalement différent de ce qui était prévu.
  3. Cet exemple n'est pas irréaliste ; nous l'avons effectivement trouvé dans un projet réel...
  4. Tout au moins comparée à la mémoire (à court terme) humaine.
  5. Ceci n'est pas limité au logiciel... Le français qui arrive aux États-Unis reste en général perplexe devant les panneaux annonçant «PED. XING». Il ignore que la lettre X, qui représente une croix, est souvent utilisée comme abréviation pour le mot «cross» (croix). La signification du panneau est donc «Pedestrian Crossing» (passage piéton).

Niveau sémantique des langages

modifier

On entend souvent l'expression «langage de haut niveau». Nous avons dit précédemment que les méthodes de conception s'arrêtaient lorsque l'on avait atteint le «niveau» du langage de programmation. Mais comment définit-on le niveau d'un langage? La meilleure définition se rapporte à la position du langage dans le processus de développement : plus la formulation sera proche de la définition du problème, plus nous dirons que le langage est de haut niveau. Inversement, si le langage exprime les contraintes de la réalisation sur machine, nous parlerons d'un langage de bas niveau.

Nous allons illustrer cette notion par un exemple simple, et voir comment différents langages permettent de prendre le relais des méthodes plus ou moins tôt (cet exemple est critiquable du point de vue des algorithmes utilisés ; son but n'est que de montrer la démarche d'abaissement du niveau sémantique dans un cas simple).

Supposons que nous voulions trouver tous les diviseurs d'un nombre donné N. La spécification du problème (point de départ) peut s'exprimer comme :

Calculer la liste de tous les nombres P tels que P divise N

La première étape consiste à exprimer la description de la solution sans faire référence à un quelconque algorithme informatique. Un langage comme SETL nous permet de nous situer directement à ce niveau. On écrirait :

LISTE := [P in [1..N] | N mod P = 0];

qui se lit «Mettre dans la variable LISTE la suite des nombres P de l'intervalle de 1 à N tels que N modulo P soit égal à 0». Noter qu'à ce niveau, on ne spécifie pas d'algorithme : on exprime le résultat souhaité, et c'est le travail du compilateur de déterminer comment obtenir ce résultat[1]. Nous ne nous préoccupons pas non plus de problèmes annexes, comme de savoir quelle quantité d'espace mémoire réserver : là encore, le système n'a qu'à se débrouiller.

La deuxième étape va consister à trouver un algorithme décrivant comment obtenir la solution désirée, ce qui nous obligera également à introduire les structures de données nécessaires à sa réalisation. L'algorithme s'exprimera au moyen de constructions de haut niveau (boucles, tests, aiguillages...) et les structures de données pourront être relativement abstraites (piles, listes...). Si nous ne disposons pas d'un langage comme SETL[2], nous devrons exprimer notre conception à ce niveau. En Ada, ceci s'exprimerait comme :

declare
	type Index is new Positive range 1..N;
	Prochain : Index := 1;

	subtype Diviseurs is Natural range 0..N;
	Non_Alloué : constant Diviseurs := 0;

	Liste : array (Index) of Diviseurs 
	      := (others => Non_Alloué);
begin
	for P in 1..N loop
		if N mod P = 0 then
			Liste (Prochain) := P;
			Prochain := Prochain+1;
		end if;
	end loop;
end;

Nous avons choisi de représenter notre liste au moyen d'un tableau, ce qui nous pose des contraintes d'implémentation. Pour pouvoir ajuster nos dimensionnements, nous supposerons que la variable N est déclarée du type Integer[3]. Nous ne savons pas a priori combien il y aura de diviseurs, mais il ne peut y en avoir plus de N. Nous allons déclarer un type pour repérer les éléments dans le tableau; bien entendu, la nature de ce type n'a aucun lien avec le type de N: nous choisissons donc un type différent, et non un sous-type. Pourquoi avoir choisi de le dériver de Positive plutôt que de Integer? Tout type servant à compter ne peut intrinsèquement avoir que des valeurs positives ; en revanche, le fait que ce type soit limité à N provient de notre problème particulier. Il est donc logique d'exprimer cela en faisant dériver le type Index de Positive, puis en le contraignant à l'intervalle 1..N.

Natural et Positive sont deux sous-types prédéfinis de Integer, comportant respectivement les nombres positifs ou nuls et les nombres strictement positifs.

En revanche, les diviseurs que nous allons stocker sont de même nature que N : nous préférons donc déclarer Diviseurs comme un sous-type de Natural, et nous exprimons qu'en plus tous les diviseurs sont inférieurs ou égaux à N. Pourquoi avoir autorisé la valeur 0? Nous avons fait ici le choix de conception d'initialiser à 0 les éléments non significatifs du tableau (encore une fois ce n'est ni le seul, ni même certainement le meilleur choix possible). Nous devons donc autoriser cette valeur «de garde». Cependant, pour exprimer ce rôle spécial de la valeur 0, nous déclarons la constante Non_Alloué, qui nous servira plutôt que la valeur 0 elle-même là où ce sera nécessaire : nous exprimons ainsi que nous ne considérons pas 0 comme un diviseur.

Il ne nous reste plus qu'à exprimer qu'une liste est un tableau repéré par un Index de Diviseurs garni au départ uniquement de valeurs Non_Alloué. Nous avons choisi d'appeler Prochain la variable servant à repérer l'endroit où mettre le diviseur, pour bien marquer qu'elle désigne le prochain élément à remplir, et non le dernier rempli[4]. Remarquez qu'à ce niveau, notre préoccupation principale a été d'exprimer autant que possible au moyen du langage Ada la connaissance que nous avions du domaine de problème et des propriétés de la solution, notamment au niveau du typage.

Les notions utilisées à ce stade (tableau dynamique, boucle à compteur, types abstraits) sont encore assez éloignées de ce que peut connaître la machine. Celle-ci ne manipule que des types élémentaires (adresses, octets, mots mémoire) et des instructions très simples (tests et branchements). L'étape suivante va donc consister à représenter les éléments abstraits au moyen des entités machine. Si nous devions maintenant écrire ce programme en langage C, il deviendrait :

main()
{
	int liste[100];
	int p;
	int *prochain;
	for (prochain = liste ;
	     prochain < liste+100 ;
	     prochain++)
		*prochain = 0;
	prochain = liste;
	for (p = 1 ; p <= n ; p++)
		if (n % p == 0)
		{
			*prochain = p;
			prochain++;
		}
}

Nous avons dû faire ici un choix d'implémentation supplémentaire : notre besoin est celui d'un tableau de taille choisie dynamiquement. C ne fournit pas cette possibilité[5] ; nous devons donc la réaliser au moyen d'un tableau de taille fixe. En général, l'espace supplémentaire sera perdu. Si au contraire l'espace est insuffisant, nous courons le risque de déborder du tableau et d'écraser les zones mémoire qui le suivent. Une bonne programmation exigerait donc de vérifier que le tableau ne déborde pas ; mais que ferait-on alors? C ne nous fournit pas non plus de mécanisme d'exception. Nous devrions donc adopter une politique particulière de traitement d'exception (comme d'avoir systématiquement une variable de retour pour signaler si la fonction s'est bien terminée), politique qui devrait être traitée par l'appelant... On voit que le traitement complet des cas exceptionnels nous entraînerait vite très loin, et c'est pourquoi beaucoup de programmeurs C préfèrent prévoir largement les tableaux et prier pour qu'ils ne débordent pas...

Le fait que nous voulions une initialisation particulière du tableau ne peut plus être donné simplement : nous devons écrire une boucle pour expliquer à l'ordinateur comment initialiser le tableau. Il nous faut même expliquer comment réaliser cette boucle : initialisation, test de fin, incrément... Enfin, les différentes propriétés que nous connaissons sur les types de données sont perdues : nous ne manipulons plus que des adresses (pointeurs) et des int, c'est-à-dire des entiers machine.

Cette description utilise donc un niveau adapté au fonctionnement de l'ordinateur, mais encore suffisamment abstrait pour être indépendant d'une architecture machine particulière. La dernière étape consiste à traduire le programme en instructions machine correspondant à l'ordinateur cible, ce que l'on fait lorsque l'on travaille en assembleur. À ce niveau, toute notion de structure disparaît : des notions aussi simples que des boucles doivent être réalisées au moyen de tests et de branchements. La description appropriée est donc l'ordinogramme : un graphe représentant le parcours exact de la machine. De même, toute notion de typage disparaît, puisque l'on ne fait même plus de différence entre des nombres entiers, flottants ou des pointeurs, ni même entre structures de programmes et structures de données : les seules notions restantes sont des instructions, des adresses et des mots mémoire. La notion de tableau, par exemple, devra être réalisée au moyen de l'indexation pour accéder à des mots mémoire consécutifs. Le langage n'est plus à même d'effectuer aucun contrôle, puisque l'on n'est même pas sûr que ce qu'exécutera l'ordinateur correspond au texte du programme (un programme peut se modifier lui-même).

Nous voyons donc que la descente de niveaux sémantiques est essentiellement un passage du « quoi » (expression de besoin) au « comment » (solution au besoin). Bien entendu, il n'est nécessaire de spécifier le « comment » que si le langage ne fournit pas directement l'outil adéquat. Par exemple, nous avons eu l'enchaînement : besoin : liste (OK en SETL), réalisée par des tableaux de taille variable (OK en Ada), réalisés par des tableaux de taille fixe (OK en C), réalisée par une zone mémoire indexée. Remarquer que nous nous sommes arrêtés là parce que nous avons supposé que la machine fournissait directement l'abstraction nécessaire : sur une machine plus élémentaire, nous aurions pu devoir réaliser la notion d'indexation par un calcul d'adresse explicite et une indirection. Remarquons également que la descente dans les niveaux d'abstraction s'accompagne d'une perte d'information : la version SETL exprime quasiment directement le besoin ; en Ada, nous ne voyons plus explicitement la notion de liste. En C, toute l'information sur les relations logiques entre les différentes données est perdue. Enfin en assembleur, les variables deviennent totalement indifférenciées. Les physiciens (et les théoriciens de l'information) diraient que l'entropie du système augmente.

Si cette information n'est plus visible, il est possible de la reconstituer, de même qu'il est possible de faire diminuer l'entropie : mais comme en physique, cela coûte beaucoup d'énergie : il faut reconstruire le comportement dynamique du système (ce qui se passe à l'exécution), puis identifier quelle structure abstraite de plus haut niveau pourrait avoir ce comportement pour implémentation. Inutile de dire que ce processus est nettement plus difficile que la démarche inverse qui consiste à passer de la structure abstraite à son implémentation. Ce qui nous amène à une autre règle d'or :

Tout comportement doit être décrit au moyen de la structure de plus haut niveau disponible.

C'est dans ce cadre que l'on peut également régler la fameuse « polémique du GOTO », qui fit rage dans les années 70, mais qui n'est pas encore totalement évacuée pour certains. En gros, le GOTO est généralement considéré comme un ordre contraire aux bons principes de programmation, mais d'aucuns argumentent que finalement, les structures de contrôle ne sont que des GOTO déguisés. C'est exact : en fait le rôle d'un langage de haut niveau est précisément de cacher les structures de bas niveau, en leur attribuant un plus haut niveau sémantique. Donc si vous travaillez en un langage de bas niveau (Le défunt FORTRAN IV, le Basic « standard »), vous devrez utiliser des GOTO, et il n'y a pas de honte à cela, à condition que dans une étape de conception précédente vous ayez exprimé votre algorithme au moyen de concepts de plus haut niveau.

Insistons sur un point : quel que soit le langage de programmation utilisé, toutes les étapes que nous avons décrites devront être accomplies... mais pas nécessairement par le programmeur. Le compilateur travaille en procédant de la même façon : une phase d'analyse de haut niveau, suivie d'une phase d'expansion destinée à abaisser le niveau sémantique de la description, suivie enfin d'une phase de génération de code. Le niveau d'un langage correspond donc au niveau sémantique à partir duquel un outil automatique (le compilateur) est capable de prendre le relais du programmeur. Un langage est d'autant plus haut niveau qu'il prend le relais du programmeur plus tôt, c'est-à-dire qu'un plus grand nombre de ces étapes seront accomplies automatiquement.

Automatiquement, certes, mais accomplies tout de même ! Ceci explique pourquoi il est normal que, toutes choses égales d'ailleurs, il faille plus de temps pour compiler un programme écrit dans un langage de plus haut niveau. [Ber89] rapportent qu'il faut quatre fois plus de temps pour compiler un programme Ada qu'un programme Pascal faisant la même chose ! D'ailleurs, les compilateurs simples ne se contentent-ils pas d'une ou deux passes alors que les compilateurs Ada en ont souvent près de huit ? C'est simplement que partant de plus bas, les premiers ont moins de travail à faire. Ceci ne signifie pas que le travail correspondant à l'abaissement du niveau sémantique ne doive pas être fait : simplement il doit être accompli par le programmeur au lieu d'être pris en charge par le compilateur. Pour être honnête, il faut donc ajouter au seul temps de compilation des langages de bas niveau l'effort de conception supplémentaire (et le coût des erreurs introduites) dû à ce plus bas niveau. À ce moment, les avantages du haut niveau redeviennent évidents : [Ber89] rapportent que depuis que les équipes sur lesquelles ont été effectuées leurs mesures ont abandonné Pascal pour Ada, les factures mensuelles passées en temps de compilation ont diminué... simplement grâce au fait que de nombreuses erreurs qui n'étaient diagnostiquées qu'à l'exécution (et qui nécessitaient plusieurs cycles de re-compilation pour être identifiées) sont maintenant «piégées» dès la première compilation.

Notons enfin que le gros risque avec les langages de bas niveau est de court-circuiter des étapes. Nous avons vu comment abaisser progressivement le niveau sémantique d'un projet. Mais l'outil final (le langage) étant en général accessible au concepteur, celui-ci tendra à passer directement de l'expression de haut niveau au codage. Cela peut marcher pour des petits projets, mais lorsque la taille du saut (sémantique) augmente... on finit par se casser la figure.

  1. Ce principe de description du résultat à obtenir et non du moyen d'y parvenir est également à la base du langage SQL dans le domaine des bases de données.
  2. Ou si nous ne sommes pas prêts à payer en temps d'exécution le prix de la facilité offerte par ce langage...
  3. Plus vraisemblablement, dans un programme correctement écrit, elle serait déclarée d'un type dérivé d'Integer.
  4. Noter qu'il existe un cas particulier où le programme tel qu'il est écrit lèvera l'exception Constraint_Error. Nous laissons à la perspicacité du lecteur le soin de le découvrir...
  5. Tout au moins pour des variables locales classiques, comme le permet Ada. Il faudrait faire appel à l'allocateur et gérer un niveau de pointeur supplémentaire.

Apport des langages de haut niveau

modifier

Les premiers langages de programmation étaient orientés machine : on forçait en quelque sorte le programmeur à penser avec le même vide culturel que la machine. Au moins était-on sûr qu'il n'y avait pas de risque d'interprétations divergentes. En fait, c'était bien entendu extrêmement pénible, ce qui a conduit à l'apparition de langages de plus haut niveau, c'est-à-dire plus proches de la façon de penser du programmeur. Ce faisant, on a également éloigné le niveau de lecture du programmeur du niveau de lecture de l'ordinateur : on a donc accru le risque de divergence entre les deux. Quel est donc l'intérêt des langages de haut niveau ?

Il provient essentiellement de ce que ces langages permettent de communiquer à l'ordinateur une partie du « savoir » supplémentaire que possède le programmeur. Autrement dit, au lieu de forcer le programmeur à comprendre le programme comme le fait le compilateur, on a la possibilité d'indiquer à celui-ci un certain nombre d'éléments « culturels » qui rapprocheront son analyse du texte de celle effectuée par l'être humain. Ceci permettra au compilateur d'effectuer des tests beaucoup plus nombreux, et surtout situés à un niveau sémantique bien supérieur. Le programmeur devra donc prendre soin de tenter de décrire, non plus les éléments de la machine dont il a besoin, mais les éléments de son domaine de problème. C'est pourquoi les types prédéfinis, qui appartiennent au domaine de la machine, devront (en général) être évités. Prenons un exemple.

Dans beaucoup de programmes, et quel que soit le langage de programmation, il est fréquent de trouver des déclarations comme :

Compteur : Integer;

Nous prétendons que cette déclaration est trompeuse, non maintenable, dangereuse, non portable, inefficace, et pour tout dire, opposée aux principes même du génie logiciel ! Pourquoi ?

  • Elle est trompeuse et non maintenable, car elle autorise des valeurs négatives pour Compteur, alors qu'un compteur ne peut contenir que des valeurs positives ou nulles. Bien sûr, le lecteur humain sait qu'un compteur ne peut être négatif, mais cette information n'est pas fournie au compilateur. Il n'y a donc aucune garantie que la variable sera effectivement utilisée comme compteur. Le programmeur de maintenance avisé devra alors résoudre une question difficile : est-ce que le programmeur d'application a utilisé le type Integer par simple paresse, ou n'y a-t-il pas quelque endroit où il a mis une valeur négative dans Compteur pour réaliser une «grosse astuce»? Le seul moyen de résoudre la question est d'aller inspecter toutes les utilisations de la variable.
  • Elle est dangereuse et non portable, parce qu'elle ne se préoccupe pas de la question de la borne supérieure. Tout type de donnée possède nécessairement une limite aux valeurs qu'il peut stocker. Il est vraisemblable que le programmeur n'a voulu qu'une variable «entière», et n'a même pas songé à cette limite, dont la valeur dépend ici de l'implémentation. Trop souvent, les programmeurs déclarent des variables du type Integer simplement pour éviter d'avoir à penser au problème de la borne supérieure, comme s'il s'agissait réellement d'entiers mathématiques. Un tel programme, qui fonctionne parfaitement sur une machine 32 bits, peut avoir un comportement imprévisible sur une machine 16 bits.
  • Elle est inefficace, parce qu'une définition plus précise aurait permis au compilateur d'utiliser la mémoire de façon plus efficace, et même de générer un meilleur code. Supposons que cette variable serve à indexer un tableau de 10 éléments; si elle avait été déclarée avec une contrainte correspondant à sa vraie utilisation, le compilateur aurait été capable de n'utiliser qu'un octet de mémoire (au lieu de 2 ou 4), et surtout aurait pu éliminer toutes les vérifications de débordement lorsqu'elle était utilisée pour indexer le tableau.
  • Plus que pour toutes ces excellentes raisons, elle est contraire aux principes du génie logiciel parce que tout le savoir qu'avait le concepteur des propriétés de sa variable n'a pas été écrit, et ne sera donc pas transmis aux personnes chargées de la maintenance.

Qu'aurait-il dû faire? Étudier le domaine de problème et refléter ses connaissances dans l'expression du langage, ce qui aurait donné :

type Valeurs_à_compter is range 0..40_000;
Compteur : Valeurs_à_compter;

Noter que le compilateur aurait utilisé la précision de cette déclaration pour réserver juste l'espace nécessaire : 16 bits si la machine disposait d'entiers non signés, 24 ou peut-être 32 sinon, mais de toute façon le type le plus économique permettant de répondre au besoin exprimé.

Ceci n'est pas possible avec un langage qui travaille au niveau machine ; quel type aurions-nous choisi en C, s'il nous fallait être portable? Certainement pas int (et encore moins short), car même si l'on peut supposer qu'il soit au moins sur 16 bits sur la plupart des machines, on ne peut supposer plus, et c'est insuffisant ici. unsigned ? Le langage n'offre aucune garantie qu'il dispose d'une étendue plus grande que int. Nous aurions dû nous rabattre sur long (ou mieux unsigned long), avec le risque de réserver 64 bits par variable sur certaines machines, là où 16 auraient suffi.

Les types de données abstraits

modifier

Puisque nous voulons désormais travailler sur les éléments du domaine de problème, il nous faut utiliser non des types machine, mais des abstractions d'éléments du monde réel. Les types de données correspondants sont appelés types de données abstraits. Un tel type est caractérisé par un ensemble de valeurs et un ensemble d'opérations qui lui sont applicables.

Si cette notion de type de donnée abstrait est commode pour l'utilisateur, elle est fort éloignée de ce que peut traiter un ordinateur : des nombres. Par conséquent, chaque attribut et chaque opération doivent être implémentés au moyen d'éléments de plus bas niveau. Il existe donc deux vues différentes d'un type de donnée abstrait : la vue abstraite, ou externe, qui exprime les propriétés utilisables de l'abstraction (ce que l'on appelle dans la terminologie Ada la spécification) et les mécanismes utilisés pour concrétiser cette abstraction (pour Ada, l'implémentation). Comme pour tout élément logiciel, la solution au problème de l'implémentation d'une abstraction donnée n'est jamais unique ; cependant, toutes les solutions possibles doivent être considérées comme équivalentes du point de vue de l'utilisateur, tant qu'elles fournissent des abstractions sémantiquement équivalentes[1].

En principe, il devrait donc être possible de remplacer n'importe quelle implémentation d'une abstraction par une implémentation différente sans perturber les utilisateurs. Si une spécification dépend malencontreusement d'un mécanisme d'implémentation particulier, cela s'appelle une sur-spécification : la spécification est trop précise, car elle mentionne des éléments qui n'appartiennent plus à la vue abstraite. Attention : une spécification peut imposer des contraintes (en temps d'exécution, en mode de gestion de la mémoire) qui interdisent certaines implémentations : il n'y a pas sur-spécification tant que ces contraintes résultent du besoin extérieur, et non d'une vue a priori d'une méthode d'implémentation particulière.

Inversement, si un utilisateur fait appel à des propriétés d'un type de donnée abstrait qui ne font pas logiquement partie de l'abstraction, on dira qu'il viole l'abstraction. Dans un cas comme dans l'autre, il devient impossible de remplacer une implémentation par une autre, et l'indépendance entre spécification et implémentation est perdue.

  1. Sauf éventuellement du point de vue des performances.

Le langage comme outil de vérification

modifier

Éviter toute sur-spécification est une tâche difficile qui requiert un grand talent d'abstraction, mais prévenir les violations d'abstraction peut être obtenu par les vérifications effectuées par le langage. Est-ce si important que le langage vérifie cela ? Et à quoi cela sert-il après tout que le langage effectue des vérifications ?

Reprenons le problème à la base. À un moment donné, l'ensemble des valeurs de toutes les variables d'un programme constitue ce que l'on appelle le vecteur d'état. Le plus vaste vecteur d'état comporte toutes les combinaisons de bits possibles pour toutes les variables du programme. Nous appellerons cet ensemble les états matériels. Mais seul un sous-ensemble de celui-ci est autorisé par les règles d'un langage de programmation de haut niveau, puisque le compilateur effectue (au moment de la compilation ou de l'exécution) un certain nombre de vérifications, qui empêchent certaines combinaisons de se produire. Nous appellerons ce sous-ensemble les états autorisés. À l'intérieur de ce sous-ensemble, un ensemble encore plus réduit est constitué des états accessibles par une exécution correcte du programme. Nous appellerons ce dernier ensemble les états corrects. Ces différents états sont résumés dans la figure 8.

 
Figure 8 : Les différents états d'un programme
Figure 8 : Les différents états d'un programme

Les états matériels doivent nécessairement englober les états autorisés, autrement le langage ne serait pas compilable (sur cette machine). De même, les états autorisés doivent inclure les états corrects, autrement le programme désiré ne pourrait être écrit dans le langage. Notez cependant que les états autorisés ne peuvent coïncider exactement avec les états corrects : cela reviendrait à prouver formellement le programme. Il y a donc nécessairement des états incorrects, correspondant à la zone des états autorisés n'appartenant pas aux états corrects, qui peuvent être atteints en fonction des seules règles du langage; c'est la responsabilité du programmeur de faire en sorte que ceci ne se produise jamais durant l'exécution du programme. D'une certaine façon, un «bug» est une brèche par laquelle on atteint un état incorrect.

C'est sur ce schéma que l'on comprendra le mieux la différence fondamentale de philosophie entre les langages C/C++ et Ada. C a été conçu comme une alternative à l'assembleur ; ses concepteurs l'ont même qualifié d'«assembleur portable». Comme en assembleur, l'idée était de permettre de faire faire «ce que l'on voulait» à la machine ; la crainte était que le langage empêche le programmeur de faire ce qu'il souhaitait. Le langage a donc cherché à étendre au maximum les états autorisés pour les rapprocher au maximum de l'ensemble des états matériels. C'est en ce sens que beaucoup considèrent que C est un langage très puissant : il permet de tout faire.

Tout, y compris des catastrophes... L'approche suivie par Ada est radicalement différente. Le but était d'avoir un langage fiable et de haut niveau pour des applications critiques. Il fallait pour cela non seulement fournir des outils permettant la définition de vues abstraites, mais également interdire l'accès aux détails de l'implémentation afin d'éliminer la plus grande partie des états incorrects. Le but recherché était l'inverse de celui de C : il s'agissait de resserrer l'ensemble des états autorisés autour des états corrects, éliminant ainsi autant d'états incorrects que possible. Vu ainsi, on peut (et même on doit) se poser la question : à quoi cela pourrait-il servir d'autoriser des états incorrects? La vraie question n'est donc pas pourquoi faudrait-il contraindre, mais quel pourrait être l'intérêt de ne pas contraindre.

Cette approche présente tout de même un risque : il peut arriver qu'à force de restreindre les états autorisés, on ampute une petite partie des états corrects. Autrement dit, le langage ne laisse pas, au nom de règles de sécurité, le programmeur faire ce qui lui est nécessaire. Certaines échappatoires (Unchecked_Conversion par exemple, nous y reviendrons) ont été prévues au niveau du langage, mais malgré tout il peut arriver que le langage interdise certaines pratiques qui bien qu'utiles ou même nécessaires dans certains cas, seraient trop dangereuses en général. Le programmeur devra donc changer sa conception pour se plier aux règles générales. Cette nécessité (qui paraît révoltante au programmeur C!) ne paraîtra choquante que dans le monde du logiciel. Toutes les autres branches de l'industrie sont coutumières du fait, et savent qu'elles doivent ajuster leurs conceptions à des règles de sécurité dont l'applicabilité est parfois discutable compte tenu des particularités du contexte, mais qui ont force de loi. Songez par exemple que le défunt Concorde était muni de doubles commandes mécaniques, dont il était de notoriété publique qu'elles étaient quasiment inutilisables dès que l'avion avait pris une certaine vitesse... et qui ont coûté très cher dans le bilan de poids de l'appareil. Mais personne n'aurait osé prendre la responsabilité de les supprimer, et c'est normal, car il s'agissait d'un dispositif de sécurité fondamental.

Quel est l'avantage d'une telle approche? Si on recherche une erreur dans un programme qui compile, on peut être assuré que les règles du langage sont respectées. Autrement dit, on peut garantir que le vecteur d'état du programme appartient toujours à l'ensemble des états autorisés[1]. En réduisant le nombre d'états autorisés mais incorrects, un langage rigoureux diminue la probabilité d'erreurs (moins d'états incorrects sont accessibles) et facilite la maintenance (moins d'états incorrects sont à envisager lors de la recherche d'erreurs). De plus, le compilateur pourra faire usage de cette connaissance supplémentaire pour optimiser le programme. Pour prendre un exemple concret, si l'on doit manipuler des couleurs, il est possible depuis Pascal (le langage !) de définir un type énumératif :

type Couleurs is (Noir,  Rouge,   Bleu, Vert,
                  Jaune, Magenta, Cyan, Blanc);

En faisant ainsi, on garantit qu'il n'est pas possible de multiplier deux couleurs, ni d'affecter à une couleur une valeur numérique incorrecte. Il paraît équivalent de déclarer :

Noir  : constant := 0;
Rouge : constant := 1;
...

Après tout, n'est-ce pas ce que fait le compilateur? Et cela marchera aussi bien que le type énumératif, tout au moins tant que personne ne tentera de violer l'abstraction. Ce que l'on perd, c'est la vérification par le compilateur que seules les propriétés abstraites sont disponibles. Représenter des couleurs par des nombres est une façon d'implémenter la notion abstraite de couleur, ce qui est le travail du compilateur. Si vous le faites vous-même, vous autorisez l'utilisateur de l'abstraction à travailler au niveau de l'implémentation plutôt qu'au niveau abstrait, et vous ouvrez ainsi une voie vers des états incorrects.

Enfin, l'avantage peut-être le plus important de l'approche de haut niveau est le contrôle qu'elle peut exercer sur la conception. Puisque maintenant le compilateur a de l'information sur les éléments de plus haut niveau du problème, une faute de conception se traduira souvent par une incohérence au niveau du typage qui sera interceptée par le compilateur. Combien de fois avons-nous vu des programmeurs nous appeler à la rescousse pour un problème de langage, alors qu'il s'agissait en fait d'un problème de conception! En voici un exemple typique :

Un étudiant débarqua un jour dans mon bureau en grognant : «Ada est vraiment un langage stupide. Regardez ça.» Il travaillait sur le simulateur d'un ordinateur un peu particulier. Il avait utilisé un tableau d'Integer pour représenter la mémoire. La mémoire était divisée en pages de 512 mots. Il avait donc les déclarations suivantes :

type Mémoire is array (0..32767) of Integer;
type Page    is array (0..511)   of Integer;

M : Mémoire;
P : Page

Le problème était que le compilateur refusait l'affectation suivante :

P := M(0..511);	-- Tranche des 512 premiers 
							-- éléments de M

alors que la boucle suivante, qui était sémantiquement strictement équivalente, était acceptée :

for I in 0..511 loop
	P (I) := M (I);
end loop;

Évidemment, du point de vue du langage, il y avait de bonnes raisons. La première affectation s'effectuait entre une variable de type Page et une expression de type Mémoire ; comme il s'agissait de deux types différents, l'affectation était interdite. Dans le second cas, on n'affectait que des composants, tous du type Integer, et chacune des affectations était autorisée.

Je ne tentai même pas de m'aventurer dans une telle explication, car l'étudiant aurait pensé (comme le lecteur le pense actuellement) que le typage fort ne fait qu'apporter des complications au malheureux utilisateur en l'obligeant à écrire une boucle explicite. La conversation continua comme ceci :

Moi : Pourquoi avoir déclaré le type Page?
Étudiant : Parce que j'en avais besoin!
Moi : Pensez-vous qu'une page soit une entité de nature différente d'une mémoire?
Étudiant : Hmm... Non, c'est une sorte de mémoire.
Moi : Quelle est donc la différence?
Étudiant : Une page fait toujours 512 mots, alors que la mémoire peut avoir n'importe quelle taille a priori.
Moi : Ah! Comment peut-on exprimer ceci en Ada?
Étudiant : J'y suis : une Mémoire est un type tableau non contraint, et Page est un sous-type contraint de Mémoire!

Il modifia son programme de la façon suivante :

type Mémoire is array (Natural range <>) of Integer;
subtype Page is Mémoire (0..511);

M : Mémoire(0..32767);
P : Page;

et dès lors, son affectation fonctionna sans problème, puisque P et M appartenaient au même type. Que pouvons-nous conclure de cet exemple? Le programme de l'étudiant comportait une faute de conception, puisque l'utilisation qui était faite des types ne correspondait pas aux abstractions souhaitées, ce qui avait provoqué l'erreur de compilation. Le mouvement naturel de l'étudiant avait été de s'en prendre au langage et de chercher un moyen de tourner le contrôle. Il y était arrivé en ne travaillant pas globalement sur les objets, mais au niveau des composants. Remarquez que cela était possible parce que le type Mémoire était visible : s'il s'était agi d'un type privé, il n'aurait pas pu s'en sortir aussi aisément. En fait, il était descendu d'un niveau d'abstraction, puisqu'il ne travaillait plus globalement sur la Mémoire, mais sur la façon dont cette mémoire était représentée. À ce plus bas niveau, la sémantique était plus pauvre, et il avait pu violer l'abstraction.

Cet exemple montre bien comment un langage très strict peut piéger les fautes de conception ; si une telle situation était apparue en C++ (en C, aucun contrôle de ce type n'est possible), il est vraisemblable que l'étudiant se serait contenté d'un vigoureux forçage de type (type cast) pour obliger la compilation à passer... et cacher le problème de conception. Dans le contexte du génie logiciel, où fiabilité et facilité de maintenance sont primordiaux, le langage de programmation doit fournir des contrôles rigoureux pour éliminer un maximum d'états incorrects dès l'étape de compilation. On peut résumer ceci ainsi :

Ce qui fait la valeur d'un langage de programmation, ce n'est pas ce qu'il autorise, c'est ce qu'il interdit.
  1. Sauf en cas de bug du compilateur. Ceux-ci sont suffisamment rares pour que l'on puisse faire cette hypothèse, mais c'est ce qui rend les erreurs dues aux compilateurs si difficiles à trouver.

Autres formes de protection

modifier

La programmation est un domaine plein de pièges subtils, dont certains requièrent une grande attention pour être évités... à moins que le langage ne prenne en charge les problèmes. Si les types abstraits sont l'outil de base de protection du programmeur, il existe d'autres cas où une formulation de plus haut niveau permet d'obtenir une meilleure sécurité.

Un exemple caractéristique est celui des boucles en C. Prenez un programmeur C moyen (ou même expérimenté), et demandez-lui comment il écrirait l'équivalent de la boucle Ada :

for I in A .. B loop
	...
end loop;

Il vous répondra immédiatement :

for (I = A; I <= B; I++) {
	...
}

Demandez-lui alors ce qui se passe si, par exemple, I est un octet (ou plus exactement un char, puisqu'en C les caractères sont des nombres!), que A vaut 0 et B vaut 255. Ce cas de figure n'a rien d'exceptionnel : il s'agit d'une simple boucle sur l'ensemble des caractères. Horreur! Lorsque I atteint 255, il repasse à 0, car il n'y a pas de notion de débordement en C[1], et la boucle ne s'arrête jamais! Ce qui est surprenant (et inquiétant), c'est que nous avons tenté cette expérience avec de nombreux programmeurs C, et qu'aucun n'a jamais pensé au problème! On est donc en droit de penser que tous les programmes C marchent par hasard, parce qu'on ne s'aventure jamais trop près des cas limites... Bien entendu, des tests de la suite de validation Ada assurent que la boucle produite par le compilateur marche toujours correctement dans ce cas de figure. Le programmeur Ada est donc protégé par le langage d'une erreur grave, sans même avoir à s'en préoccuper.

Cet exemple montre bien que le langage peut effectivement fournir un degré de sécurité supplémentaire qui n'a rien à voir avec un quelconque problème de conception. Remarquons qu'encore une fois, ce résultat est obtenu par le fait que la description Ada se situe un niveau d'abstraction au-dessus de la description C : le programmeur décrit ce qu'il veut obtenir (une boucle de 0 à 255) et non comment l'obtenir (par une affectation, un test et un incrément – algorithme dont nous venons de montrer qu'il était faux)[2].

  1. En toute rigueur, la norme C n'interdit pas de «planter» le programme dans ce cas, mais la plupart des compilateurs ne le font pas. Inutile de penser à un traitement d'exception (même en C++, qui a pourtant les exceptions, les débordements ne sont pas traités).
  2. Signalons au lecteur qui chercherait la solution correcte en C qu'il n'est pas possible de coder une boucle for (au sens d'Ada) au moyen d'une simple boucle while (dont le for C n'est qu'une variante). Il faut nécessairement un exit (break en C).

Et l'efficacité?

modifier

Si l'utilisation de fonctionnalités de haut niveau améliore indiscutablement la lisibilité, la sécurité et la maintenabilité, l'on est en droit de se demander si cela n'a pas un effet adverse sur l'efficacité. En fait, l'efficacité est souvent invoquée comme raison d'utiliser des langages de plus bas niveau. Voyons donc plus précisément ce problème.

Tout d'abord, il faut savoir de quelle efficacité l'on parle. Il existe souvent des contraintes temporelles au cahier des charges : celles-là doivent être impérativement respectées, car un programme qui ne répond pas à son cahier des charges est un programme faux. La question de l'efficacité se pose seulement après : si le programme vérifie largement ses contraintes, comment va-t-on utiliser la marge supplémentaire? Les machines utilisées au début de l'informatique étaient incroyablement lentes par rapport aux machines (mêmes individuelles) actuelles : on disait alors que le bon programmeur était celui dont les programmes allaient le plus vite possible. À l'époque, les programmes étaient de taille plus modeste qu'aujourd'hui, et l'on se souciait peu de la maintenance... Cette mentalité n'a plus lieu d'être aujourd'hui : un bon programme, c'est d'abord un programme évolutif, fiable et facile à maintenir. De plus, la puissance des machines rend acceptables des solutions autrefois impossibles. Ceci ne signifie pas que l'efficacité ne doive pas être recherchée, mais seulement que, encore une fois, il faut trouver le bon compromis, car ce que l'on gagne en efficacité, on le perd sur d'autres aspects, tels que lisibilité et portabilité. Par exemple, imaginons un programme interactif (comme une interrogation de base de données). S'il donne la réponse au bout de dix secondes, c'est inacceptable (et vraisemblablement trop pour les contraintes du cahier des charges) : il faut faire quelque chose. Si on améliore les performances pour que les réponses parviennent en moins d'une seconde, c'est bien ; l'attente est sensible pour l'utilisateur, mais acceptable. Si l'on fait passer ce temps à un dixième de seconde, c'est parfait : la réponse semble instantanée. Si l'on poursuit dans cette voie pour obtenir un temps de réponse d'un centième de seconde, on perd son temps : l'utilisateur ne sera pas sensible à la différence, et on risque de le payer cher sur d'autres aspects.

D'autre part, il n'est pas du tout évident qu'une description de plus haut niveau se traduise nécessairement par une perte d'efficacité; au contraire, le compilateur est capable d'utiliser le supplément d'information pour mieux optimiser le code. Considérons cet exemple :

	I	: Integer; -- Ce qu'il ne faut pas faire !
	S1	: String(1..10);
	S2	: String(1..10);
begin
	I			:= Calcul_compliqué;	-- (1)
	S1(I)	:= S2(I);					-- (2)

Il n'y a pas de vérification au moment de l'affectation à I en (1), mais comme l'on ne sait pas a priori quelle est la valeur retournée, il faut vérifier à chaque utilisation (deux fois en (2) ) que la valeur est correcte. Si nous informons le compilateur de notre intention d'utiliser cette variable pour indexer des chaînes comme ceci :

subtype Index is Integer range 1..10;
	I	: Index;
	S1	: String (Index);
	S2	: String (Index);
begin
	I			:= Calcul_compliqué;      -- (1)
	S1(I)	:= S2(I);					  -- (2)

alors le compilateur effectue une vérification lors de l'affectation en (1), mais il n'est plus nécessaire d'en effectuer lors des utilisations en (2). Comme on utilise les variables plus souvent qu'on ne les modifie, cette approche est généralement préférable. C'est tellement vrai que beaucoup de programmeurs sont déçus lorsqu'ils tentent d'accélérer leurs programmes en les compilant « sans vérification » : le gain excède rarement quelques pour-cents. Ceci signifie simplement que l'optimiseur a été capable d'éliminer de lui-même tous les tests redondants... et donc que le (petit) gain d'efficacité se fait certainement au détriment des tests réellement importants, donc avec un impact maximal sur la fiabilité! Conséquence : en général, en Ada, on laisse toutes les vérifications même dans la version finale, commercialisée, du programme.

Il faut savoir que les techniques modernes d'optimisation permettent d'obtenir des résultats surprenants. Nous avons ainsi connu un programmeur qui avait écrit un programme de façon « ignoble », persuadé que cela irait plus vite. Il reçut du compilateur le message suivant :

WARNING: frame of control too complicated; optimizer gives up.[1]

Du coup, le programme allait moins vite que s'il avait écrit son code proprement et laissé l'optimiseur faire son travail... À travers cet exemple, on notera la différence d'état d'esprit : le rôle du compilateur C est de fournir une traduction directe vers les instructions machine, l'optimisation étant du ressort du programmeur. Du coup, un compilateur C, même relativement simple, produira un code acceptable – d'où la réputation d'efficacité traditionnellement attachée au C. Inversement, le rôle du compilateur Ada est de libérer (autant que possible) le programmeur des contraintes de bas niveau ; un compilateur sans optimiseur performant serait catastrophique, mais le langage a été conçu pour permettre des optimisations très perfectionnées. Il est donc possible d'optimiser beaucoup plus du code Ada que du code C (cf. [Tar93] pour un exemple de programme initialement développé en C qui n'a pu tenir ses performances qu'après un recodage en Ada).

Enfin, l'on ne saurait trop rappeler que la recherche d'efficacité doit se faire d'abord par la recherche d'algorithmes performants. Les bénéfices que l'on peut en retirer sont de plusieurs ordres de grandeur supérieurs à ce qui peut être obtenu par des «astuces» de codage. Le programmeur doit toujours garder à l'esprit la règle des 90/10 : un programme passe 90% de son temps dans 10% de son code ; or ces fameux 10% sont en général très difficiles à identifier. La seule constante que nous ayons rencontrée dans des projets où se posait un problème d'efficacité est que le point critique n'était jamais là où le supposait le programmeur[2]. Il est indispensable de disposer d'outils de mesure lors de toute recherche d'amélioration des performances.

En conclusion, nous dirons que l'efficacité est importante, mais que ce n'est plus le seul critère à prendre en compte pour juger de la qualité d'un programme, et que les principes du génie logiciel continuent à s'appliquer même pour l'écriture de programmes critiques sur le plan des performances. Quand l'on voit la fréquence avec laquelle l'efficacité sert de justification abusive à des pratiques peu recommandables, on ne peut que conclure par une paraphrase :

Efficacité, que de bugs on commet en ton nom!
  1. AVERTISSEMENT : Structure de contrôle trop compliquée ; l'optimiseur abandonne.
  2. Nous avons connu ainsi un compilateur Pascal qui passait l'essentiel de son temps... dans la boucle de lecture de caractères. La réécriture en assembleur de la seule procédure de lecture d'un caractère a permis de doubler la vitesse du compilateur.

Conclusion

modifier

Le typage fort et la vérification par le langage des abstractions sont des dispositifs de sécurité indispensables : ils ne peuvent être pris à la légère. Cette démarche est universellement adoptée dans d'autres branches de l'industrie. Par exemple, une machine dangereuse comme un massicot ne peut fonctionner que si l'opérateur appuie simultanément sur deux boutons, disposés de telle façon qu'il soit obligé d'utiliser ses deux mains : on est ainsi assuré qu'il n'y a pas une main qui traîne sous la lame. On pourrait penser que l'opérateur d'un massicot a une perception beaucoup plus directe du risque qu'il y aurait à laisser une main dans la machine que le programmeur d'utiliser un forçage de type ; il est cependant connu qu'en l'absence de dispositif de sécurité, des accidents se produisent. Il n'y a pas de raison de supposer que les choses se passeraient différemment en programmation. Remarquons au passage que l'utilisation de dispositifs de sécurité dans l'industrie ennuie les opérateurs et diminue bien souvent la productivité ; mais l'on considérerait comme inacceptable de sacrifier la sécurité à de tels impératifs.

Donner au développement de logiciel un caractère industriel demande que l'on reconnaisse la nécessité d'un contrôle plus pointu, et que la sécurité des systèmes complexes ne peut pas ne dépendre que du talent des individus qui les conçoivent. Nous conclurons en illustrant cette différence fondamentale dans la perception du rôle du programmeur par le rapprochement de deux citations tirées respectivement d'introductions aux langages C et Ada :

C a été conçu dans l'idée que le programmeur est quelqu'un de raisonnable et qui sait ce qu'il fait.
Ada a été conçu en tenant compte du fait que le programmeur est un être humain.

Exercices

modifier
  1. En C++, il est possible de définir des classes fournissant la notion de tableau avec contrôle de débordement. Quels en sont les inconvénients par rapport à des tableaux vérifiés par le compilateur ? Discuter sur le plan des principes du génie logiciel et sur le plan des optimisations possibles du code généré.
  2. Rechercher dans les langages autres qu'Ada les restrictions qui sont d'ordre méthodologique et celles dues à la technique du compilateur. Que peut-on en conclure ?
  3. Nous avons mis en garde le lecteur contre l'utilisation du type Integer. Ces arguments sont-ils applicables au type String ? Au type Boolean ?
  4. Écrire au moyen d'une boucle loop simple l'équivalent exact de la boucle for d'Ada. Attention, cet exercice est plus difficile qu'il n'y paraît !

Liaison entre méthode et langage

modifier

Lorsque l'on doit intervenir sur un programme existant pour corriger des erreurs, lui apporter des améliorations ou lui faire subir une révision majeure, on est amené à intervenir sur le code, bien sûr, mais à l'intérieur d'un cadre qui provient de la méthode. Comme toujours, nous considérons que celui qui effectue la maintenance n'est pas le concepteur initial. Il faut donc commencer par comprendre la philosophie générale de la conception, puis déterminer comment l'évolution pourra être effectuée sans perturber la structure générale, ou, si ce n'est pas possible, faire évoluer la conception elle-même. L'énorme différence par rapport à ce qui se passe en conception initiale est qu'à ce moment, le code existe déjà. Il importe donc d'être capable de déterminer l'impact de toute modification de la conception sur le code. Ce problème porte le nom général de traçabilité : être capable de «suivre à la trace» (dans les deux sens) les liens qui existent entre la conception et le code.

Le problème de la documentation

modifier

L'idée la plus naturelle est de considérer que la documentation est là justement pour assurer cette traçabilité. Depuis des années, la question de la documentation des conceptions a été centrale à la démarche de rationalisation de la production de logiciel... et toujours aussi difficile à obtenir :

La documentation est l'huile de ricin de la programmation : cela doit bien servir à quelque chose, puisque les chefs de projets insistent tant pour en avoir....

Nous avons vu que la bonne compréhension par un être humain d'un programme s'appuyait sur une part «culturelle», c'est-à-dire sur des éléments ne figurant pas dans le texte du programme. Une bonne partie de la difficulté de reprendre le programme de quelqu'un d'autre tient à ce phénomène : la «culture» d'un programmeur est différente de celle d'un autre. Même si l'on reprend son propre programme quelques mois après l'avoir écrit, il faut un certain temps avant de «reconstituer le contexte» nécessaire à sa compréhension. On comprend mieux alors le rôle de la documentation : elle permet de transmettre cette information nécessaire à la compréhension du programme qui ne se trouve pas dans le texte du programme. Cela explique aussi pourquoi les programmeurs sont généralement réticents à l'écrire, et pourquoi elle est généralement si mal faite : elle doit mettre noir sur blanc ce qui paraît absolument évident à son auteur... mais pas forcément à quelqu'un d'autre ; le programmeur aura donc du mal à identifier ce qui posera problème à un futur mainteneur doté d'une autre «culture», et risquera d'insister lourdement sur des détails inutiles, tout en laissant de côté des points fondamentaux. Nous avons été ainsi amené à relire des spécifications pour un système de menus. La documentation fournissait abondance de détails sur toutes les interactions possibles, parlant par exemple de saisie d'éléments de menu par l'utilisateur, chose qui nous a paru totalement contradictoire avec la notion même de menu... jusqu'au moment où nous avons compris que ce que le rédacteur avait appelé menu était ce que nous appelions masque de saisie. Le programmeur avait tout simplement oublié de décrire la nature même de ce dont il parlait.

La documentation souffre d'un autre problème : même si elle existe, il convient de la mettre à jour au fur et à mesure de l'évolution du projet ou des modifications dues à la maintenance. Un programmeur de maintenance consciencieux devrait rajouter à la documentation tous les points qu'il aurait souhaité y trouver et qu'il a dû reconstituer à partir du programme à grand-peine... c'est rarement le cas.

Ce problème est rendu encore plus aigu par le fait que la plupart des méthodes confondent démarche de conception et documentation. Lors de la conception, un certain nombre de documents sont produits qui expriment l'état d'avancement du projet et de la réflexion des concepteurs. Concrètement, une méthode se traduit de façon visible par la production de ces documents. Pour les concepteurs, cette documentation devient en quelque sorte obsolète une fois le projet entré en phase de codage, et les programmeurs éprouvent rarement le besoin de la remettre à jour en cas de modification apparaissant tard dans le processus de développement. La documentation nécessaire par la suite doit se donner pour but de permettre à une personne nouvelle dans un projet de comprendre sa structure et les décisions de conception qui ont été prises ; il n'y a aucune raison a priori que celle-ci coïncide avec les documents de la méthode, qui reflètent plutôt l'historique de la conception. Mais comme cette seconde documentation existe rarement, la première en tient lieu.

La documentation est donc rarement complète, appropriée et à jour. C'est un point faible du développement logiciel, mais elle est indispensable pour porter l'information qui ne peut s'exprimer directement dans le texte du programme. Si l'on pouvait exprimer tout le contexte culturel du programmeur dans le programme lui-même, et si le langage était d'assez haut niveau pour correspondre directement aux éléments de la conception, alors la documentation de programmation deviendrait inutile.

Vers l'auto-documentation

modifier

Encore une fois, ce sont les éléments tellement «évidents» (pour le concepteur initial) qu'ils ne figurent nulle part qui sont la cause d'une grande partie des difficultés de maintenance. Par conséquent,

Tout le savoir du programmeur doit être exprimé dans le texte du programme.

De quelle façon peut-on formuler ce savoir? La première idée qui vient à l'esprit est d'utiliser dans ce but les commentaires. Nous parlons ici bien entendu des commentaires algorithmiques ; les en-têtes de modules (auteur, historique, etc.) sont indispensables et jouent un rôle différent. Hélas, ils souffrent des mêmes défauts que la documentation séparée, hormis le fait qu'ils figurent textuellement dans le corps de programme, ce qui facilite leur mise à jour simultanée lors de modifications ; en particulier, il est possible de modifier le programme sans mettre à jour les commentaires correspondants. En cas de désaccord, le programmeur croira toujours le code contre le commentaire. [Lan91] conseille même, lorsque l'on a à intervenir dans un code écrit par quelqu'un d'autre, d'ignorer systématiquement tous les commentaires : en effet, si l'auteur a fait une erreur, le commentaire risque d'induire le lecteur dans la même erreur! On peut même dire qu'un commentaire est un aveu de faiblesse de la part du programmeur : s'il éprouve le besoin de clarifier les choses, c'est qu'il n'a pas écrit son programme de façon qu'il soit directement compréhensible.

Le commentaire est la plus mauvaise forme d'expression du savoir du programmeur.

Les possibilités de typage très rigoureux d'Ada ouvrent une nouvelle voie : exprimer une partie du savoir sous une forme compilable, donc vérifiable par le compilateur. Supposons par exemple que nous voulions un type destiné à compter quelque chose, et considérons les déclarations suivantes :

type    Compte_1 is new Integer   range 0..1000;
type    Compte_2 is new Natural   range 0..1000;
type    Compte_3 is new Comptable range 0..1000;
subtype Compte_4 is     Comptable range 0..1000;

Selon toute vraisemblance, le code généré par ces quatre déclarations sera exactement le même ; il n'est donc pas question ici de considérer des différences d'efficacité. La déclaration de Compte_1 ne nous apporte aucune information supplémentaire. Compte_2 nous montre que le programmeur voit effectivement son type comme un compteur, puisqu'il le dérive d'un type qui ne peut intrinsèquement pas être négatif. Compte_3 nous apporte une information supplémentaire : c'est un type dérivé de Comptable ; il porte donc une dépendance conceptuelle à ce type (en particulier, ceci nous informe – et le compilateur vérifiera – que Compte_3 ne peut pas s'étendre au-delà de Comptable). Il s'agit cependant d'un type de nature différente, puisque c'est un type dérivé ; alors qu'avec Compte_4, il ne s'agit que d'un sous-type, donc d'entités de même nature que Comptable.

On voit bien sur cet exemple que le fait d'avoir plusieurs façons d'exprimer la même chose devient une aide, car cela permet de transmettre un renseignement supplémentaire au lecteur : s'il y a un choix, le fait d'adopter une solution plutôt qu'une autre est porteur d'information. Plus les possibilités de typage du langage seront riches, plus il y aura de choix d'implémentations possibles, donc d'information transmise à la fois au compilateur (qui pourra faire des vérifications plus précises) et au lecteur. Ceci fonctionne remarquablement bien... à condition que le programmeur ait effectivement utilisé toutes les possibilités du typage.

Si l'on adopte cette façon de faire, il ne doit plus rester dans un programme que deux formes de commentaires : les en-têtes de module (de format standardisé, selon les normes du projet), et l'expression d'invariants, éléments supposés toujours vrais à un endroit du programme. Et encore, ces derniers peuvent faire l'objet d'une expression dans le langage (et donc d'un contrôle automatique). Il suffit de disposer de la procédure suivante (on peut en faire des versions plus perfectionnées) :

procedure Assert (Condition : Boolean) is
begin
	if not Condition then
		raise Program_Error;
	end if;
end Assert;

On peut alors exprimer les invariants sous la forme :

Assert( Taille (Pile) >= 2 );

Si l'invariant n'est pas satisfait, il y aura levée de l'exception Program_Error : encore une fois, les hypothèses du programmeur peuvent être vérifiées automatiquement par le langage.

En résumé, les possibilités de typage d'Ada permettent de transmettre beaucoup plus d'information au lecteur que d'autres langages. Ce n'est bien entendu pas une garantie ; mais en pratique, on s'aperçoit rapidement, lorsque l'on reprend un programme écrit par quelqu'un d'autre, à quel point l'on peut faire confiance aux déclarations. Et de toutes façons, la situation ne peut être pire qu'avec les autres langages où ce type de documentation n'est pas possible. Cela demande plus de travail au programmeur? Certes, mais pas plus que de mettre des commentaires, pour un niveau de sécurité bien meilleur ; et comme le remarquait [Lan91] :

Un programmeur à l'esprit ordonné écrit des instructions limpides. un programmeur à l'esprit embrouillé fait d'obscurs commentaires.

Tu ne coderas point avant d'avoir conçu

modifier

Une fois que nous avons admis que notre connaissance des objets que nous manipulons doit s'exprimer dans le langage, nous pouvons nous poser la question : ne peut-on aller plus loin? Ne peut-on également utiliser Ada pour exprimer nos décisions de conception, avant même d'atteindre la phase de codage? Nous touchons là à un principe sacro-saint : on ne doit pas commencer à écrire du code tant que les phases de conception ne sont pas totalement terminées. Mais quelle est l'origine de ce principe? Revenons d'abord sur la démarche de conception elle-même.

Au départ, on trouve un problème particulier à résoudre, dans le cadre du projet. Mais ce problème n'est souvent qu'un cas particulier d'un besoin plus général, qu'il nous faut identifier. Enfin, nous devons trouver une implémentation répondant au besoin.

À l'aube de l'humanité (c'est-à-dire avant l'apparition d'Ada), le langage de programmation n'était considéré que comme une suite d'instructions données à une machine. Autrement dit, il ne permettait d'exprimer que la troisième étape. Le programmeur en revanche n'était préoccupé que de la première : trouver une solution à son problème. En codant directement ce qui lui paraissait une solution, il court-circuitait totalement la deuxième partie : la vraie réflexion sur la nature du problème.

Prenons par exemple un dispositif destiné à faciliter l'accordage d'un piano. Après avoir mesuré la fréquence d'une note jouée, il faut rechercher la note théorique la plus proche, et indiquer l'écart entre la fréquence mesurée et la fréquence théorique de la note (besoin particulier). Ce besoin n'est en fait qu'un cas particulier du problème général de situer une valeur par rapport à une fonction tabulée. La solution à un problème d'informatique n'est jamais unique, par conséquent il existera de nombreuses façons différentes d'implémenter cette notion abstraite. Dans notre exemple, il pourra exister différentes formes d'organisations de la table des valeurs et plusieurs algorithmes de recherche possibles (les implémentations). Ici, le programmeur (qui travaillait en Pascal, mais venait de FORTRAN) n'avait songé à utiliser que l'outil normal à tout faire de FORTRAN : la boucle DO (ou for en Pascal). Il avait donc codé un algorithme de parcours séquentiel de la table, et trouvait son code insuffisamment performant (il fallait reconnaître les notes en temps réel). La table des fréquences des notes étant naturellement triée, il fallait bien sûr utiliser une recherche dichotomique. L'algorithme trouvait ainsi la bonne note (parmi 128) en 7 comparaisons (au pire) au lieu de 64 (en moyenne) avec la recherche séquentielle. Soit une accélération de presque un facteur 10...[1]

Le passage trop rapide du problème particulier au codage risque donc de faire passer à côté d'autres solutions plus performantes, ou bien de faire adopter une solution à court terme impossible à faire évoluer. La règle habituelle, qui interdit au programmeur de coder avant d'avoir formalisé son problème, sert à l'obliger à passer par cette deuxième étape de reformulation. Le point fondamental n'est donc pas de concevoir avant de coder : c'est de formaliser le problème avant d'adopter une implémentation particulière.

Faute de passer par cette étape, il n'est plus possible de faire la différence entre un concept et son implémentation. Et comme chaque couche de logiciel s'appuie sur d'autres couches plus profondes, des dépendances transitives aux implémentations vont s'établir à travers tous les niveaux, conduisant à de véritables fuites d'abstraction. Un exemple typique de ceci est un problème auquel se trouvent confrontés les développeurs utilisant DBase III sur PC. La séquence de code pour faire passer une imprimante en mode double largeur se termine par l'envoi du caractère NUL. Or, le mode d'emploi de DBase III spécifie bien «qu'il n'est pas possible d'envoyer un caractère NUL sur l'imprimante». La raison en est évidente à quiconque a pratiqué les langages de programmation : DBase est écrit en C, et c'est l'habitude en C d'utiliser le caractère NUL comme terminateur de chaîne, ce qui interdit de le faire figurer dans une chaîne. Il n'empêche qu'il n'est pas possible d'écrire en double largeur dans une application DBase à cause d'un choix de représentation d'une structure de donnée particulière dans le langage qui a servi à écrire le compilateur DBase! L'origine du problème se trouve trois niveaux d'abstraction plus bas, mais C est typiquement un langage qui «fuit» : le besoin abstrait est celui d'une chaîne de caractères, mais le comportement des abstractions est entièrement gouverné par le choix de représentation sous-jacent : impossible d'ignorer qu'une chaîne de caractères est en fait un pointeur sur un octet, ni qu'un tableau n'est en fait que l'adresse de son premier élément[2].

  1. Pour que l'histoire soit complète, notons que ce programmeur était venu nous voir à l'origine pour savoir si un case était plus performant qu'un if. Bien sûr, on ne peut espérer mieux à ce niveau qu'un gain de quelques pour-cents de performance dans le meilleur des cas. Bel exemple de ce que nous avons dit à propos de la recherche d'efficacité...
  2. Signalons au passage à ceux qui ne voient pas le problème ici qu'en Ada, la valeur interne d'une variable tableau n'est généralement pas l'adresse de son premier élément.

Le parcours horizontal du V de développement

modifier

Les différentes méthodes de conception recouvrent différentes façons de formaliser les problèmes. Or Ada nous offre, au moyen des spécifications indépendantes des implémentations, la possibilité d'exprimer la formulation abstraite sous une forme compilable, c'est-à-dire vérifiable (dans une certaine mesure) au moyen d'un outil automatique, le compilateur. Cette possibilité a un effet considérable sur tout le processus de développement.

On représente habituellement les étapes de la conception au moyen du «V» de développement, qui représente le modèle dit de la « chute d'eau » (Figure 9).

 
Figure 9 : Le modèle de la chute d'eau
Figure 9 : Le modèle de la chute d'eau

On part du cahier des charges, dont on tire l'analyse générale, puis la conception détaillée et le codage. On ne passe à l'étape suivante qu'après avoir validé l'étape précédente. Une fois le codage effectué, on va vérifier en remontant en sens inverse : au codage correspond la mise au point, à la conception détaillée correspondent les test unitaires, à la conception générale correspond l'intégration, et enfin au cahier des charges correspond la recette.

Le principal problème de ce modèle est qu'il est extrêmement sensible aux erreurs. Si l'on a commis une faute au niveau de la conception générale, elle ne sera trouvée qu'au moment de l'intégration, et sa correction nécessitera une nouvelle itération à travers tout le cycle de développement (d'où la bombe!). On a tenté de pallier cette difficulté en multipliant les niveaux de contrôle, mais il semble utopique d'espérer éliminer totalement la possibilité d'erreurs.

En revanche, si nous considérons les différentes étapes de conception comme des vues de plus en plus concrètes de l'expression d'un problème, nous pouvons à chaque étape formaliser la conception au moyen de spécifications Ada. Ces spécifications sont compilées, et donc vérifiées. On n'aborde les étapes ultérieures qu'après avoir vérifié que les différents éléments de plus haut niveau peuvent bien s'assembler de la façon souhaitée. On va donc en quelque sorte effectuer l'intégration avant même d'aborder la conception détaillée! Ceci aboutit à ce que nous appelons le parcours horizontal du «V» de développement, représenté sur la figure 10. Nous verrons, dans la troisième partie de cet ouvrage, comment cette façon de faire peut être systématisée pour développer des applications par maquettage progressif.

 
Figure 10 : Le parcours horizontal du «V» de développement
Figure 10 : Le parcours horizontal du «V» de développement

Ces considérations peuvent paraître théoriques : il n'en est rien, et l'une des remarques qui revient le plus souvent lorsqu'on interroge des responsables de projets qui sont récemment passés à Ada est la mention d'un véritable effondrement des temps d'intégration. Ce n'est pas étonnant : le langage est un gardien tellement vigilant qu'il empêche la construction de pièces qui ne pourraient s'assembler par la suite.

La nouvelle documentation de maintenance

modifier

À partir du moment où nous disposons d'un langage suffisamment puissant pour permettre l'expression directe et vérifiée de nos conceptions, la documentation traditionnelle est-elle appelée à disparaître? Certainement pas, mais son rôle va être modifié.

Tout d'abord, la documentation de conception restera, car il importe de garder l'historique des choix principaux qui ont conduit à la structure actuelle du projet. La documentation réellement mise en cause est la documentation de codage. Elle ne doit plus être une description des algorithmes effectivement choisis, puisque ceux-ci sont mieux décrits par le code, ou alors se situer à un niveau nettement supérieur (description au moyen de langages formels) : elle doit essentiellement servir à tracer les raisons des choix qui ont abouti à la sélection de telle ou telle politique d'implémentation.

On ne le répétera jamais assez : la solution à un problème d'informatique n'est jamais unique. Une excellent habitude consiste d'ailleurs, lorsqu'un choix d'implémentation paraît évident, à systématiquement chercher une deuxième possibilité, quitte à la réfuter immédiatement : on est ainsi assuré d'avoir fait un choix délibéré, et non un choix implicite, c'est-à-dire provenant simplement d'un manque de réflexion sur le problème.

Il arrive également fréquemment que l'on s'aperçoive après coup qu'un choix d'implémentation n'était pas le bon, et que l'on doive revenir sur une décision antérieure, parce que l'on n'avait pas envisagé certaines difficultés. Le rôle de la documentation de bas niveau va être essentiellement de garder la trace de ces choix, des raisons qui les ont motivés, et en cas de retour arrière, des raisons du choix initial, des difficultés rencontrées, et des motivations de la remise en cause. Ceci apportera au lecteur ultérieur une meilleure compréhension du problème, et surtout lui évitera de refaire lors d'une opération de maintenance les erreurs commises au moment du développement. Combien de fois n'avons-nous pas eu l'impression que le concepteur initial était passé à côté d'une solution «évidente», alors qu'en fait celle-ci ne pouvait fonctionner pour des raisons qui n'apparaissaient que bien plus tard!

En résumé, on peut dire que le rôle de la documentation de maintenance n'est plus de décrire la solution adoptée, mais les raisons et les différents compromis qui ont conduit à préférer cette solution à d'autres. En particulier, le développeur consciencieux mentionnera les évolutions possibles qui peuvent amener à reconsidérer les choix : par exemple, une solution a pu être préférée à une autre pour des raisons d'efficacité, mais l'arrivée d'un nouveau matériel plus performant peut remettre en cause ce choix.

Exercices

modifier
  1. Reprendre la documentation de maintenance d'un logiciel réel et la critiquer en fonction des critères de ce chapitre. Décrit-elle les principes ou les détails de la solution? Un nouveau venu y trouverait-il rapidement les éléments lui permettant de comprendre la structure du projet? Les informations n'auraient-elles pas pu s'exprimer dans le langage? Etc.
  2. Expliquer pourquoi il serait bon d'écrire le mode d'emploi «utilisateur» d'un programme avant d'écrire le programme.