Méthodes de génie logiciel avec Ada/Deuxième partie

Nous avons vu dans la première partie le rôle important joué par les langages du point de vue méthodologique. Dans cette deuxième partie, nous passons en revue les différentes grandes classes de méthodes de conception, en montrant comment Ada leur fournit un soutien actif, et ceci quelle que soit la méthode.

Méthodes avec Ada

Car là est bien l’enjeu et l’ambition d’Ada : être le langage qui soutient toutes les méthodes.

Les méthodes structurées

modifier

Principes de la méthode

modifier

La programmation structurée fut historiquement la première formalisation de l’analyse par décomposition descendante. Le principe de cette méthode est simple : on considère qu’un programme est constitué d’une suite d’actions élémentaires. On décompose donc au plus haut niveau le problème à résoudre en actions individuelles que l’on suppose résolues; on analyse ensuite chacune de ces actions en la décomposant sous forme d’actions de plus bas niveau, et ainsi de suite jusqu’à obtenir des actions considérées comme triviales et susceptibles de s’écrire directement dans le langage de programmation. La programmation structurée peut donc être définie comme une méthode dont le critère de décomposition horizontale est l’enchaînement des actions, et le critère de décomposition verticale la généralité des actions.

Le langage dont la philosophie se rapproche le plus de la programmation structurée est Pascal. On peut voir l’arbre de décomposition de la programmation structurée comme l’arbre d’imbrication des procédures d’un programme Pascal : un programme principal, qui se décompose en procédures de premier niveau, elles-mêmes décomposées en procédures de deuxième niveau, etc.

Ada, possédant un sous-ensemble équivalent à Pascal, permet bien entendu cette approche des problèmes. Il lui apporte cependant un perfectionnement important : il est possible d'imbriquer logiquement des modules, tout en conservant la possibilité de les compiler séparément. Le Pascal standard ne possède pas de mécanisme de compilation séparée. Les Pascal «étendus» (tels que Turbo Pascal par exemple) possèdent de tels mécanismes, mais à condition de ne pas conserver la notion d'imbrication. Le mécanisme des corps séparés permet, comme nous allons le voir dans l'exemple suivant, de compiler séparément des modules tout en conservant l'imbrication logique.

Exemple en programmation structurée

modifier

Supposons que nous voulions imprimer un triangle de Pascal (le mathématicien, pas le langage !). Il s'agit d'un triangle de nombres, se présentant comme à la figure 11.

 
Figure 11 : Triangle de Pascal
Figure 11 : Triangle de Pascal

Chaque élément d'une ligne s'obtient en faisant la somme de l'élément situé au-dessus de lui et de l'élément situé au-dessus et à gauche, le dernier élément à droite (qui n'a pas d'élément supérieur) étant toujours un 1. Pour résoudre le problème global (imprimer le triangle), il suffit de savoir résoudre deux sous-problèmes : calculer une ligne, et l'imprimer. Nous avons également besoin d'une structure de données pour communiquer entre ces deux étapes : la Ligne. Le programme principal peut s'écrire :

procedure Triangle_Pascal is
	Max : constant := 10;
	Ligne : array(1..Max) of INTEGER;
	procedure Calculer_Ligne (Numéro : INTEGER) is separate;
	procedure Imprimer_Ligne (Numéro : INTEGER) is separate;
begin
	for I in 1..Max loop
		Calculer_Ligne (Numéro => I);
		Imprimer_Ligne (Numéro => I);
	end loop;
end Triangle_Pascal;

Nous pouvons compiler ce programme pour vérifier qu'il ne contient pas d'incohérence à ce niveau de décomposition, alors que nous n'avons encore pris aucune décision de conception en ce qui concerne les niveaux inférieurs.

La clause is separate permet de compiler séparément un corps de sous-programme, tout en gardant toutes les propriétés (notamment la visibilité et le contrôle des types) qu'il aurait s'il se trouvait effectivement à la place de la clause.

Ensuite, nous devons descendre d'un niveau en nous préoccupant de savoir comment nous allons réaliser les procédures Calculer_ligne et Imprimer_ligne. Comme les lignes sont calculées dans l'ordre, lorsque Calculer_ligne est appelé la variable Ligne contient la ligne précédente (d'ordre Numéro-1). Nous pouvons utiliser cette information pour calculer la ligne d'ordre N, à condition de calculer de droite à gauche, afin de ne pas «écraser» des valeurs dont nous avons encore besoin. Le premier élément valant toujours 1, il est inutile de le recalculer, et nous savons que le dernier vaut également toujours 1. Nous pouvons donc écrire :

separate (Triangle_Pascal)
procedure Calculer_Ligne(Numéro : Integer) is
begin
	Ligne (Numéro) := 1;
	for I in reverse 2..Numéro-1 loop
		Ligne (I) := Ligne (I) + Ligne(I-1);
	end loop;
end Calculer_ligne;

Au niveau d'abstraction supérieur, nous n'avions pas prévu de traitement spécial pour les premières lignes; nous devons donc vérifier ce qui se passe, pour éventuellement faire un cas spécial. Si Numéro vaut 1, la procédure met la valeur 1 dans Ligne(1), et la boucle n'est pas effectuée (Numéro-1 vaut 0, qui est plus petit que 2). Le traitement est donc correct. Si Numéro vaut 2, le premier élément est intouché, on met 1 dans le deuxième élément, et la boucle n'est pas effectuée : le traitement est encore correct. Enfin, à partir de 3, l'algorithme normal s'applique. Il n'y a donc pas à faire de cas particulier, ce qui n'était pas évident au départ. Pour imprimer, nous pouvons représenter le comportement souhaité de la façon suivante : écrire la ligne d'ordre I revient à écrire les I premiers éléments, suivis d'un retour à la ligne. Noter qu'à ce niveau, nous ne savons pas comment écrire un élément : nous reportons ce problème à l'étape suivante. Nous pouvons donc écrire :

with Ada.Text_IO;
separate (Triangle_Pascal)
procedure Imprimer_Ligne (Numéro : Integer) is
	procedure Imprimer (Elem : Integer) is separate;
	use Ada.Text_IO;
begin
	for I in 1 .. Numéro loop
		Imprimer (Ligne (I));
	end loop;
	New_Line;
end Imprimer_Ligne;

Il ne nous reste plus qu'à analyser comment imprimer un élément simple. Ada fournit des entrées-sorties prédéfinies sur le type Integer dans le paquetage Ada.Integer_Text_IO.

Ce paquetage n'est en fait qu'une pré-instanciation du paquetage générique Text_IO.Integer_IO, qui permet d'avoir des entrées-sorties sur n'importe quel type entier. Il n'était pas obligatoire en Ada 83.

Nous allons donc utiliser ce composant :

with Ada.Integer_Text_IO;
separate (Triangle_Pascal.Imprimer_Ligne) 
procedure Imprimer (Elem : Integer) is
	use Ada.Integer_Text_IO;
begin
	Put (Elem, Width =>2);
end Imprimer;

Bien sûr, cet exemple est un peu exagérément détaillé, car son but est de montrer la démarche : pour imprimer un triangle de Pascal, on suppose que l'on sait calculer et imprimer une ligne. Pour imprimer une ligne, on suppose que l'on sait imprimer un élément. Chaque étape s'appuie sur des problèmes plus spécifiques, que l'on suppose résolus, puis que l'on implémente. Inversement, chaque étape de la résolution s'appuie sur une vue de plus en plus précise, mais de plus en plus étroite du problème : Imprimer_ligne ne traite que de l'impression, sans se préoccuper de la façon dont la ligne a été calculée. Imprimer s'occupe uniquement de l'impression d'un élément, sans savoir où ni pourquoi on le lui demande. Enfin, chaque niveau s'exécute séquentiellement dans le temps, et sait dans quel ordre il est appelé : c'est ce qui permet à Calculer_ligne d'utiliser le résultat du calcul précédent.

Critique de la méthode

modifier

Lors de son émergence, la programmation structurée représentait un grand progrès par rapport aux méthodes, ou plutôt à l'absence de méthodes, existantes. Cependant, avec les progrès de l'informatique et l'extension du domaine des problèmes susceptibles d'être résolus de façon informatique, elle bute sur un certain nombre de difficultés que nous allons résumer maintenant.

Cette méthode conduit à une topologie de programme purement arborescente, comme illustrée par la figure 12. Chaque module est fait spécifiquement en fonction des exigences du niveau supérieur : ceci conduit à une architecture de programme très monolithique, semblable à un puzzle où chaque morceau est destiné à entrer à un endroit précis pour lequel il a été conçu. Il y a donc un couplage spatial entre les modules : comme un module «voit» tous les éléments qui lui sont supérieurs, il n'est pas possible de recopier un module dans une autre branche du programme. De plus, le développeur sait quand, dans le déroulement du programme, son module sera appelé : il peut donc faire des suppositions sur l'état des variables, ce que fera le prochain module appelé, etc[1]. Ceci entraîne également un couplage temporel des modules : le même sous-programme, appelé depuis un endroit qui n'était pas celui initialement prévu, risque de ne plus fonctionner normalement.

 
Figure 12 : Topologie de programme en programmation structurée
Figure 12 : Topologie de programme en programmation structurée

Une autre conséquence de l'aspect monolithique des programmes ainsi développés est qu'il est très difficile d'autoriser les compilations séparées. Pascal en standard ne les permet pas. Si de nombreuses extensions de Pascal permettent de le faire, c'est toujours en répétant dans chaque module les déclarations globales qu'il est censé voir; une modification d'un de ces objets globaux doit être répercutée dans tous les modules qui l'utilisent, et le langage ne garantit absolument pas la cohérence des différentes déclarations[2]. Tout ceci entraîne que les modules développés pour une application peuvent difficilement être réutilisés dans un contexte différent. Même à l'intérieur d'une même application, on trouve souvent des fonctionnalités très voisines, mais légèrement différentes, ce qui conduit à une duplication de codes quasi identiques. Ceci est d'ailleurs une conséquence inévitable de la hiérarchisation. C'est ce que nous allons montrer maintenant.

Il arrive souvent qu'une fonctionnalité identique se retrouve à différents endroits d'un même programme; on peut imaginer par exemple que les modules marqués X et Y sur la figure 12 aient tous deux besoin d'effectuer des lectures sur le terminal, et que l'on souhaite passer par une procédure permettant d'éditer la ligne par exemple. Dans la décomposition strictement arborescente de la programmation structurée, ceci s'exprimerait par le fait que deux modules, a priori totalement indépendants, expriment tous deux dans le cours de leurs actions la sous-action «lire une ligne». En suivant strictement la méthode, il faudrait écrire le même sous-programme deux fois dans deux modules distincts. C'est évidemment peu souhaitable; ce n'est pas tant la duplication de l'écriture qui est gênante (un bon éditeur de texte le fait en quelques touches) ni même l'espace mémoire gaspillé par la duplication du code (les machines ont des espaces mémoire importants de nos jours) : le problème réel vient de la maintenance. Si une modification doit être faite dans un de ces modules dupliqués, il faut la répercuter dans toutes les copies, et le risque est grand d'en oublier. On peut ainsi voir des erreurs corrigées à un endroit réapparaître soudainement à un autre; on risque également des problèmes d'incohérence : imaginez un programme où le caractère d'effacement serait Del par endroits et Backspace à d'autres!

La solution généralement adoptée consiste à remonter la fonctionnalité dans l'arbre de décomposition jusqu'à un endroit où elle sera visible de tous les modules qui l'utilisent, c'est-à-dire presque toujours jusqu'à la racine. Le module se situe alors à un niveau global qui ne correspond plus à la décomposition logique. Le même phénomène se produit pour les structures de données, lorsque deux parties du programme doivent à travailler sur une même variable. Les règles de visibilité exigent alors que la variable soit «remontée» dans l'arbre au moins jusqu'au nœud commun aux deux branches concernées.

 
Figure 13 : Dégénérescence de la topologie de programme en programmation structurée
Figure 13 : Dégénérescence de la topologie de programme en programmation structurée

On s'aperçoit donc qu'en pratique, la belle arborescence théorique décrite par la méthode dégénère pour aboutir à une topologie illustrée par la figure 13. On a alors des arborescences partielles, avec un regroupement important de fonctions et de structures de données au niveau de la racine. Remarquons qu'il s'agit d'une première entorse aux principes de la programmation structurée : des entités sont déclarées à un niveau où elles ne correspondent pas à une action élémentaire du niveau immédiatement supérieur.

Ce regroupement a une conséquence extrêmement néfaste : les éléments ainsi propulsés au niveau global sont alors visibles non seulement depuis les modules concernés, mais également depuis l'ensemble du programme. Si par exemple une variable ne doit être (logiquement) modifiée qu'en passant par l'intermédiaire d'une procédure de contrôle, on n'a plus aucune garantie qu'un programmeur «astucieux» n'a pas trouvé plus commode d'accéder à la variable directement. Il devient donc extrêmement difficile de contrôler quelle partie du programme utilise quelle autre partie, puisque toutes sont visibles entre elles. On a bien décomposé le programme en unités de tailles suffisamment petites pour être gérables, mais on a établi des liens de dépendance qui croissent rapidement, jusqu'à dépasser ce que l'on peut gérer; on ne sait plus alors très bien qui modifie quoi ou qui appelle qui dans le programme.

Notons pour terminer que la définition même de la programmation structurée suppose l'ordonnancement séquentiel des actions. Il n'est donc pas possible de l'utiliser pour définir des systèmes parallèles. On peut pallier cet inconvénient en définissant un système parallèle comme un ensemble de processus, individuellement séquentiels, mais s'exécutant en parallèle. La programmation structurée peut alors s'appliquer à l'analyse de chacun des processus, mais bien sûr l'analyse du comportement global requiert une méthode appropriée au parallélisme.

Ce problème de l'analyse des systèmes parallèles à été longtemps ignoré, ou tout au moins confiné à quelques domaines particuliers, car les langages courants n'offraient pas de possibilité de programmation parallèle, ce qui n'encourageait pas les développeurs de systèmes d'exploitation à développer le parallélisme, ce qui n'encourageait pas le développement de langages parallèles, etc. La situation change aujourd'hui, car la demande de toujours plus de puissance de calcul ne pourra être satisfaite qu'avec des machines parallèles, ce qui a amené l'introduction de processus légers (threads) au niveau des systèmes d'exploitation... ainsi bien entendu que l'apparition d'un langage de programmation industriel complet offrant, au même titre que ses autres fonctionnalités, un modèle simple de programmation parallèle[3].

  1. Qui n'a jamais entendu des remarques du genre : «Pas la peine de remettre à jour la variable X, elle est écrasée par le prochain module»?
  2. Le même reproche peut d'ailleurs être adressé à C, à FORTRAN (avec les COMMON), etc.
  3. Ada, pour ceux qui ne l'auraient pas reconnu !

Ada et la programmation structurée

modifier

Pascal avait été conçu spécifiquement pour mettre en œuvre les principes de la programmation structurée. Ada a poursuivi dans cette voie, tout en ajoutant les éléments nécessaires à un langage d'envergure industrielle.

Utilisation des sous-programmes

modifier

Ainsi que nous l'avons vu, Ada permet comme Pascal de traduire la hiérarchie des actions résultant de la programmation structurée sous forme d'imbrication de procédures, mais offre de plus la possibilité de compiler séparément les corps. On peut certes s'affranchir de la hiérarchie stricte en regroupant certains sous-programmes en paquetages; mais la hiérarchisation est toujours possible. On a prétendu [Cla80] que cette possibilité d'imbrication était inutile, voir même nocive. Les langages comme C/C++ n'autorisent d'ailleurs pas l'imbrication de sous-programmes. Il est en effet toujours possible de mettre deux sous-programmes « à côté » l'un de l'autre, au lieu de les imbriquer, avec sensiblement les mêmes fonctionnalités :

-- Sous-programmes
-- imbriqués
procedure A is
   procedure B is
        ...
   end B;
begin
    B;
end A;
-- Sous-programmes
-- non imbriqués
procedure B is
    ...
end B;
procedure A is
begin
    B;
end A;

La différence vient encore de la maintenance : si un sous-programme semble appelé à tort et qu'il est déclaré au niveau le plus externe, on doit jeter la suspicion sur l'ensemble du programme ; si au contraire il est déclaré à l'intérieur d'un autre sous-programme, l'appel incorrect a forcément lieu depuis l'intérieur de cet autre sous-programme, réduisant considérablement le champ d'investigation nécessaire pour retrouver l'erreur.

Ada permet donc de traduire la structure de la conception directement dans la structure du programme, tout en conservant la possibilité de compilation séparée. Une difficulté est que si l'on modifie une couche haute, il faudra recompiler tous les éléments (séparés) de plus bas niveau; mais ceci est un problème inhérent à la méthode, où une modification des couches hautes a des conséquences sur toutes les couches inférieures; le langage ne fait que répercuter les propriétés de la méthode.

Utilisation des paquetages

modifier

En programmation structurée, les paquetages servent essentiellement à regrouper soit des structures de données, soit des structures de programmes. On trouvera donc, selon la classification établie par Booch, essentiellement des « collections de données » et des « collections de sous-programmes ». Les principes de la programmation structurée impliquant la visibilité des données, on trouvera rarement des parties privées dans les paquetages utilisés avec cette méthode.

  1. Collection de données
  2. Une collection de données est un simple ensemble de données globales utilisées par tout le système, à l'exclusion de toute opération. En Ada, une collection de données se présentera sous la forme d'une spécification de paquetage ne comportant que des déclarations de types, de constantes, de variables ou d'exceptions. Il n'y aura généralement pas de corps de paquetage, sauf peut-être pour permettre l'initialisation des variables. Les collections de données globales existent depuis longtemps dans les langages de programmation (COMMON FORTRAN). Ada offre cependant un niveau de sécurité supplémentaire, car le compilateur gère tous les contrôles de types même entre unités compilées séparément; il n'est donc plus possible de «tricher» sur les types par le biais de ce type d'unité. Dans les méthodes de programmation structurée, on trouve souvent la notion de dictionnaire de données globales, réalisé au moyen de paquetages de type «collection de données». Bien qu'on considère généralement qu'il n'existe qu'un seul dictionnaire de données, on peut l'implémenter au moyen de plusieurs paquetages, chaque paquetage regroupant des données ayant un lien entre elles. On diminue ainsi les recompilations en cas de modification du dictionnaire de données, mais surtout le graphe de dépendances des with fournit une indication précise sur les données utilisées par chaque module.
  3. Collection de sous-programmes
  4. À l'opposé de la collection de données, on trouve la collection de sous-programmes. Ceci correspond à la notion habituelle de bibliothèque : bibliothèque mathématique, graphique, scientifique... Une telle collection s'exprime en Ada sous la forme d'un paquetage dont la spécification ne comporte que des sous-programmes (procédures et fonctions). Le point important est que le paquetage ne comporte aucun état rémanent entre deux appels de ses sous-programmes. On s'interdit donc toute modification de variable globale par un sous-programme, et l'on garantit que les valeurs renvoyées ne dépendent que des paramètres et non de l'ordre d'appel des sous-programmes. Les sous-programmes sont donc indépendants les uns des autres, et sans mémoire.
    Le pragma Pure permet de faire vérifier par le compilateur que ces conditions sont bien respectées.

Utilisation des exceptions

modifier

Les canons de la programmation structurée ne connaissent pas la notion de déroutement; l'utilisation des exceptions sera donc généralement limitée si l'on applique strictement la méthode. D'ailleurs, les règles de programmation associées aux méthodologies en programmation structurée (surtout pour les systèmes à haute sécurité) déconseillent ou interdisent l'utilisation des exceptions. L'avantage de la programmation structurée est qu'elle permet de suivre directement l'exécution du programme, propriété qui n'est plus vérifiée en présence d'exceptions. Malgré tout, il faut bien définir une politique d'erreur (nous en discuterons dans la quatrième partie); mais on utilisera plutôt une technique par codes de retour.

On ne peut cependant ignorer totalement les exceptions : d'une part, les composants logiciels n'ont pas d'autre solution que de les utiliser en cas d'impossibilité de rendre le service demandé; d'autre part, une anomalie du programme peut conduire à la levée d'une exception prédéfinie. On évitera donc en général de lever des exceptions, mais on devra prévoir quand même des traite-exceptions de sécurité. En cas de levée d'exception imprévue, on appellera la procédure de gestion normale des situations erronées.

Utilisation des génériques

modifier

Nous avons vu que la réutilisation de code en programmation structurée impliquait soit de le dupliquer, ce qui posait des problèmes de maintenance, soit de le remonter à un niveau global, ce qui violait la décomposition logique. Les génériques d'Ada offrent une solution élégante à ce problème. Supposons par exemple que nous ayons besoin de la procédure Lire_Ligne à plusieurs endroits du programme; cette procédure a pour mission de lire une ligne au terminal (en la complétant avec des espaces, éventuellement dans une fenêtre, avec des fonctions d'édition...) et de la mettre dans une certaine variable de la procédure englobante. Initialement, le programmeur avait écrit :

procedure Utilisatrice is
	Mon_Tampon : String(1..80)
	procedure Lire_Ligne is
		Longueur : Natural;
	begin
		Get (Mon_Tampon, Longueur);
		Mon_Tampon(Longueur+1 .. Mon_Tampon'Last) := (others => ' ');
	end Lire_Ligne;
begin
	...
end Utilisatrice;

Lorsqu'apparaît le besoin de réutiliser cette procédure dans un autre contexte, le programmeur ne duplique pas le code, mais le transforme en générique :

generic
	Tampon : in out String;
procedure Lire_Ligne_Générique is
	Longueur : Natural;
begin
	Get (Tampon, Longueur);
	Tampon(Longueur+1 .. Tampon'Last) := (others => ' ');
end Lire_Ligne_Générique;

Il peut ensuite, dans l'ancienne procédure ainsi que dans toutes celles qui en ont besoin, écrire :

procedure Utilisatrice is
	Mon_Tampon : String(1..80)
	procedure Lire_Ligne is new Lire_Ligne_Générique (Mon_Tampon);
begin
	...
end Utilisatrice;

Ainsi, chaque procédure utilisatrice dispose de sa propre procédure locale, respectant la structure logique, mais le code n'est écrit qu'une fois, et toute modification de maintenance qui lui serait apportée sera automatiquement reportée dans toutes les instanciations. Les génériques permettent donc d'apporter à la programmation structurée une forme de réutilisation qui ne perturbe pas la structure totalement hiérarchique de l'application.

Utilisation du parallélisme

modifier

Nous avons vu que les méthodes en programmation structurée se prêtent mal au développement de systèmes parallèles. Il faut cependant parfois définir des activités parallèles dans des systèmes développés en programmation structurée. Dans ce cas, on décompose le système en tâches indépendantes dès les premières étapes de la conception, ce qui permet ensuite de poursuivre l'analyse de chacune d'elles en programmation structurée «normale».

On trouvera donc surtout des tâches Ada définies directement dans des paquetages de bibliothèque, se comportant essentiellement comme des programmes principaux multiples. En Ada 95, on pourra également utiliser le modèle d'exécution distribuée qui permet effectivement d'avoir plusieurs programmes principaux faiblement couplés.

Notons encore que pour un système développé en programmation structurée ayant besoin de parallélisme, il est particulièrement utile de disposer d'un langage offrant un mécanisme de tâches intrinsèque : le surcroît de complexité est alors négligeable. Autrement, il faut soit s'appuyer sur les primitives d'un exécutif temps réel particulier, mais on y perd la portabilité, soit écrire soi-même son propre système de gestion des tâches, mais l'effort supplémentaire est loin d'être négligeable, et il risque souvent d'imposer des contraintes à travers toutes les couches du logiciel. C'est ainsi que les normes de programmation de systèmes graphiques tels que Windows ou XWindow demandent à toutes les applications utilisatrices de se bloquer volontairement périodiquement, pour permettre au système de « reprendre la main ».

Exercices

modifier
  1. Analyser en programmation structurée un système d'affichage de la vitesse d'un véhicule. On suppose que l'on dispose d'un dispositif physique qui fournit une impulsion à chaque tour de roue.
  2. Analyser en programmation structurée un programme qui compte le nombre de lettres, de mots et de phrases d'un fichier texte.
  3. Expliquer pourquoi la programmation structurée est très utilisée pour les systèmes à fortes contraintes temps réel et/ou de haute sécurité.

Les méthodes orientées objet

modifier

Principes des méthodes orientées objet

modifier

Les méthodes orientées objet sont nées du désir de remédier aux problèmes de la programmation structurée, sans en perdre bien entendu les avantages. Le principal mérite de la programmation structurée vient de ce qu'elle procure une méthode rationnelle, progressive, de décomposition des programmes. Celle-ci s'effectue de façon descendante, ce qui permet de mettre en place la structure générale sans se soucier des détails d'implémentation. Mais son problème essentiel vient du couplage des différents éléments de la décomposition entre eux. Ce que l'on cherche donc, c'est une méthode descendante (par raffinements successifs), mais produisant des modules à faible couplage. De plus, les modules doivent représenter chacun une et une seule entité logique, c'est-à-dire qu'ils doivent être à forte cohésion (il ne doit pas être possible de diviser un module en deux tout en conservant le faible couplage).

Définition

modifier

Pour remédier aux défauts de la programmation structurée, il convenait de remettre en cause son fondement même : la décomposition en actions. L'origine de cette réflexion se trouve dans un article de Parnas [Par71] intitulé « À propos des critères à utiliser pour décomposer un système en modules ». Il s'agissait bien d'une remise en cause des critères de décomposition.

Nous proposons d'appeler «orientée objet» toute méthode dont le critère de décomposition horizontale est l'objet, en tant qu'abstraction d'un objet du monde réel.

Mais comment définit-on ce qu'est une abstraction? Prenons un exemple. Il est vraisemblable que vous qui lisez ces lignes possédez une voiture. Vous venez de lire la phrase précédente, et vous n'avez eu aucune peine à saisir sa signification. Pourtant possédez-vous vraiment une «voiture»? Non, vous possédez un certain ensemble de tôles et de plastique que vous identifiez comme appartenant à une classe appelée «voiture»; vous possédez donc un objet qui est une voiture, mais beaucoup d'autres objets ayant peu de rapport avec votre chère auto sont également des voitures. Le terme de voiture est une abstraction, qui permet de regrouper un certain nombre d'entités différentes.

Comment donc reconnaître si un objet donné est une voiture ou non? Pour avoir droit à ce titre, il faut que l'objet en question ait quatre roues, des sièges et un moteur. Nous pouvons donc définir un certain nombre de valeurs qui sont caractéristiques de la classe voiture. Mais ceci ne suffit pas à la caractériser : un avion pourrait très bien posséder les caractéristiques précédentes. Il faut en plus spécifier que la voiture sert à transporter des gens sur la route. Nous devons donc définir, en plus des valeurs, un certain nombre d'actions que la voiture peut effectuer ou subir.

On peut donc définir une abstraction comme une réduction d'une classe d'objets à un ensemble de valeurs et d'opérations communes à tous les objets de la classe, et permettant de la définir entièrement.

Le point important de cette définition est la réduction. D'ailleurs, le terme abstraire ne signifie-t-il pas étymologiquement «retirer de»? Par exemple, une propriété d'une voiture est la composition chimique de l'acier dans lequel a été fabriqué le vilebrequin. C'est une propriété inutile pour la vue de la voiture qu'a le conducteur moyen (mais pas pour l'ingénieur qui a conçu la voiture). L'abstraction va donc consister à extraire de l'énorme masse de propriétés caractérisant les objets un petit nombre d'entre elles qui vont être suffisantes pour l'idée que l'on se fait de l'objet à un moment donné. C'est donc essentiellement ce phénomène de réduction, de projection de l'objet réel dans l'espace de problème, qui va caractériser le phénomène d'abstraction.

Avec cette technique, la définition des objets s'appuie sur les propriétés naturelles de l'abstraction du monde réel. Un programme n'est donc plus une suite d'instructions à faire exécuter par une machine, mais une description d'un certain modèle du monde réel. Il s'ensuit une réorganisation complète du logiciel : si les instructions élémentaires se retrouvent quasi identiques quelle que soit la méthode de décomposition choisie, elles ne sont pas regroupées en modules de la même façon. Il n'est pas étonnant que l'ancêtre de tous les langages à objets, Simula, ait été un langage de simulation : dans quel autre contexte aurait-on plus besoin de représenter les entités du monde réel?

De cette différence fondamentale découlent des propriétés partagées par toutes les approches objet :

  • L'encapsulation. L'objet n'est ni une structure de programme, ni une structure de données, mais regroupe en une même entité à la fois les attributs (données) et les opérations[1] (sous-programmes) associés. De plus, la structure interne (implémentation) doit pouvoir être cachée afin de garantir l'indépendance de la vue externe (abstraite) par rapport à la vue interne.
  • L'indépendance temporelle. Les objets étant définis par eux-mêmes, on peut définir le comportement d'un objet indépendamment du contexte dans lequel il est appelé.
  • L'indépendance spatiale, ou localisation. Tous les aspects relatifs à une même entité sont physiquement dans le même module. Il est donc plus facile de faire des composants autonomes.
  1. Appelées méthodes dans le jargon orienté objet.

Les objets informatiques

modifier

Un objet informatique sera donc une entité de modularisation qui rassemblera les structures de données et les opérations pertinentes pour l'utilisation qui est faite de l'objet du point de vue informatique. Différentes façons d'utiliser la notion d'objet sont possibles, que nous allons maintenant passer en revue. Bien qu'il y ait toujours possibilité d'exceptions, l'expérience montre que les «bons objets» correspondent à ces catégories. On vérifiera donc lors de la conception que ce que l'on fait correspond aux critères ci-dessous; sinon, il faut étudier s'il n'y a pas une faute de conception.

  1. Machine abstraite
  2. On peut vouloir travailler simplement avec des représentations d'objets individuels du monde réel. On appelle de tels objets des machines abstraites (ou machines à états abstraits), car ce sont des entités autonomes possédant un état bien défini et des opérations modifiant cet état; historiquement, on les a d'abord considérés comme des sortes d'automates, d'où leur nom. Les valeurs renvoyées par les opérations pourront dépendre de l'état courant, donc de l'historique des appels, ce qui différencie les machines abstraites des collections de sous-programmes utilisées en programmation structurée. On réalise en Ada une machine abstraite au moyen d'un paquetage ne comportant que des sous-programmes dans sa spécification (ainsi que, parfois, quelques types annexes nécessaires aux paramètres des sous-programmes); des variables cachées dans le corps de paquetage gardent trace de l'état courant. Par exemple, l'abstraction d'un moteur vue par le système de contrôle de régime pourrait être :
    package Le_Moteur is
    	procedure Allumer;
    	procedure Couper;
    	type Régime is range 0 .. 6_000;
    	procedure Régler_Moteur (A : Régime);
    	function Régime_Courant return Régime;
    end Le_Moteur;
    

    Le corps de ce paquetage possède des variables permanentes pour conserver la position de l'accélérateur, le réglage de l'avance à l'allumage, etc. Tous ces détails de réalisation ne sont pas visibles de l'extérieur. En principe, chaque machine est unique; on peut cependant utiliser des génériques pour obtenir plusieurs objets identiques.

  3. Type de donnée abstrait
  4. Plutôt que des objets isolés, on veut souvent créer de nombreux objets similaires. Il faut dans ce cas définir un type qui servira de modèle (on parle parfois de moule) pour définir des objets identiques. On dit que ce type est abstrait, car seules les propriétés correspondant à la modélisation de l'objet du monde réel doivent être accessibles; les contraintes de réalisation (notamment dues au langage de programmation) doivent rester cachées. On réalise en Ada un type de donnée abstrait au moyen d'un paquetage dont la spécification comporte un seul type principal, normalement privé ou limité privé, et un ensemble d'opérations portant sur ce type. Il peut également y avoir, comme pour la machine à états abstraits, des types auxiliaires. Aucun état n'est conservé dans le paquetage; toute l'information rémanente caractérisant l'état de l'objet doit être conservée dans les champs que comporte le type. Par exemple, nous pouvons représenter ainsi des compteurs simples :
    package Gestion_Compteurs is
    	type Compteur is private;
    
    	type Comptable is range 0 .. 999_999;
    	procedure Incrémenter    (Le_Compteur : Compteur);
    	procedure Remise_A_Zéro  (Du_Compteur : Compteur);
    	function Valeur_Courante (Du_Compteur : Compteur)
    		return Comptable;
    private
    	type Compteur is
    		record
    			Valeur_Affichée : Comptable := 0;
    		end record;
    end Gestion_Compteurs;
    

    Remarquer que ceci ne crée aucun objet : il faut déclarer des variables appartenant au type. Si l'on veut utiliser de tels compteurs dans une pompe à essence, on déclarera par exemple :

    Compteur_Argent : Compteur;
    Compteur_Volume : Compteur;
    
  5. Gestionnaires de données
  6. On a souvent besoin d'objets bien connus en informatique, mais qui n'ont que peu de rapport avec des objets du monde réel : piles, listes, files, fichiers, etc. Le point commun à toutes ces structures est qu'elles n'ont aucune utilité par elles-mêmes : elles ne servent qu'à organiser, stocker ou gérer d'autres entités. Pour cette raison, nous préférons en faire une catégorie bien différenciée des autres objets, bien que leur réalisation puisse se faire soit sous forme de machine abstraite, soit sous forme de type de donnée abstrait. Nous appellerons ces entités des gestionnaires de données, le terme structure de données étant utilisé dans trop de contextes pour ne pas être ambigu. Ces gestionnaires sont normalement pourvus d'opérations de type «itérateur», comme expliqué au prochain paragraphe, et correspondent aux objets «à itérateur» de la classification de Booch [Boo87].
  7. Classification des opérations sur objets
  8. L'interface entre les propriétés de l'objet et le monde extérieur passe normalement par des appels de sous-programmes. Quelques règles sont à respecter pour obtenir de «bons» objets. Le premier principe est que chacun d'entre eux doit faire une chose et une seule. De plus, de même que les composants appartiennent normalement à l'une des catégories ci-dessus, les opérations (= sous-programmes fournis) appartiennent normalement à l'une des catégories ci-dessous; toute déviation doit faire penser à l'éventualité d'une faute de conception.
    Sélecteurs
    Les sélecteurs sont des sous-programmes permettant d'interroger l'état ou les valeurs d'un objet. Ils seront souvent implémentés sous forme de fonctions. Ils ne modifient en aucun cas l'état de l'objet.
    Constructeurs
    Les constructeurs servent au contraire à modifier l'état d'un objet, soit pour l'initialiser, soit pour fournir une nouvelle valeur à une grandeur caractéristique, soit pour faire passer l'objet d'un état dans un autre (cas des automates).
    Itérateurs
    Les itérateurs sont des opérations un peu particulières, associées aux gestionnaires de données. Ils permettent de parcourir une structure en obtenant successivement tous ses éléments. On distingue les itérateurs externes et les itérateurs internes. Un itérateur externe est un ensemble de sous-programmes, comportant :
  • Une initialisation, donnant les caractéristiques des éléments à aller chercher dans la structure (éventuellement tous). Cette initialisation, selon les cas, se contente d'initialiser l'itérateur, ou fournit également la première valeur.
  • Un moyen de rendre l'élément suivant de la structure actif. Il peut fournir directement l'élément suivant, ou simplement faire progresser l'itérateur. Il lève une exception s'il est appelé alors qu'il n'y a plus d'élément dans la structure.
  • Un moyen de récupérer la valeur courante. Celui-ci peut être une fonctionnalité séparée, ou faire partie d'une des opérations précédentes.
  • Un prédicat de fin de structure permettant d'arrêter les itérations.
L'itérateur externe le plus connu est celui traditionnellement utilisé pour accéder aux fichiers : Open constitue la fonction d'initialisation, Get le passage à l'élément suivant (avec fourniture de l'élément courant) et End_Of_File le prédicat de fin de structure. Des itérateurs similaires se retrouvent dans les bases de données. Un itérateur interne vise à éviter d'extraire les valeurs de la structure, mais au contraire à effectuer un certain traitement in situ. On devra donc fournir une procédure qui sera appliquée automatiquement à toutes les valeurs contenues dans le gestionnaire de données. Ceci s'exprime typiquement comme :
generic
	with procedure Traitement	(Élément : in out Donnée);
procedure Itérer;

Les itérateurs externes sont des briques de base simples, permettant de satisfaire à peu près tous les besoins... à condition d'écrire à chaque fois l'algorithme. Les itérateurs internes sont des entités de plus haut niveau, plus simples à utiliser (le parcours de la structure est automatique), mais la réalisation de certaines opérations telles que la jointure peut se révéler difficile.

Quelle que soit la forme d'itérateur utilisé, l'ordre dans lequel sont fournis les éléments variera selon la structure de donnée : ce sera l'ordre dans lequel les éléments ont été entrés (ou l'ordre inverse) pour une liste linéaire, un ordre croissant pour une table triée, ou même un ordre aléatoire dans le cas d'une table hash-codée. Cet ordre doit bien entendu être documenté dans la description de la structure de données.

Objets actifs

modifier

Le parallélisme d'Ada permet de définir des objets actifs, c'est-à-dire évoluant en parallèle. Ceci s'obtient en associant une tâche à une structure de donnée; plusieurs façons de faire sont possibles, selon le degré de couplage souhaité entre la tâche et la structure de donnée.

Une première solution consiste à attacher manuellement une tâche à un objet. On peut pour cela utiliser un pointeur désignant l'objet comme discriminant de la tâche. Imaginons par exemple que nous voulions représenter un récipient qui fuit : toutes les 10 secondes, il perd un litre d'eau. Nous lui associons un «moniteur de fuite» qui diminue périodiquement la quantité restante :

package Compteur_fuyant is
	type Volume is delta 0.01 range 0.0 .. 100.0;
	type Compteur is
		record
			Contenu : Volume := 0.0;
		end record;

	task type Moniteur (Compteur_Associé: access Compteur);
end Compteur_Fuyant;
package body Compteur_fuyant is
	task body Moniteur is
 	begin
		loop
			delay 60.0;
			if Compteur_Associé.Contenu > 1.0 then
				Compteur_Associé.Contenu :=
				                  Compteur_associé.Contenu - 1.0;
			else
				Compteur_Associé.Contenu := 0.0;
			end if;
		end loop;
	end Moniteur;
end Compteur_Fuyant;
with Compteur_Fuyant; use Compteur_Fuyant;
procedure Test_Compteur is
	Mon_Compteur : aliased Compteur := (Contenu => 100.0);
	Ma_Tâche : Moniteur (Mon_Compteur'Access);
begin
	...
end Test_Compteur;

Avec cette solution la tâche «moniteur» est relativement extérieure à l'objet «compteur» : rien ne nous empêche de ne pas associer de tâche à l'objet, et d'avoir un compteur qui ne fuit pas. Cela peut être le comportement désiré. Si inversement nous voulons être sûrs que tous les compteurs fuient, il faut associer la tâche plus étroitement au compteur. C'est ce que permettent les auto-pointeurs :

package Compteur_fuyant is
	type Volume is delta 0.01 range 0.0 .. 100.0;

	type Compteur;
	task type Moniteur (Compteur_associé: access Compteur);

	type Compteur is limited
		record
			Contenu     : Volume := 0.0;
			Le_Moniteur : Moniteur (Compteur'ACCESS);
		end record;

end Compteur_Fuyant;

Remarquer que c'est le nom du type Compteur qui est utilisé ici comme préfixe de l'attribut 'Access. Tout objet déclaré du type Compteur contiendra une tâche Moniteur, dont le discriminant désignera automatiquement l'objet Compteur dont il fait partie; cette disposition est illustrée en figure 14. Ces autopointeurs ne sont autorisés que dans des types limités : en effet, l'affectation ne pourrait conserver la propriété de l'autopointeur de désigner sa propre structure englobante.

 
Figure 14 : Représentation d'un autopointeur
Figure 14 : Représentation d'un autopointeur
Le type Compteur utilise le mot clé limited dans sa déclaration d'article. Un tel type est appelé «intrinsèquement limité» : l'affectation est interdite même là où l'on a la visibilité sur le type complet, et le langage garantit que tous les passages se feront par adresse; il n'y a donc jamais duplication. Les autopointeurs ne sont pas limités aux tâches : on peut les utiliser pour lier n'importe quels types.

On peut réaliser ainsi des objets réellement actifs. Noter que l'on pourrait avoir plusieurs tâches associées à un même objet.

Sémantique de valeur et sémantique de référence

modifier

Il existe en fait deux sortes d'objets qu'il importe de bien différencier : ceux à sémantique de valeur et ceux à sémantique de référence[1]. Ils se distinguent par la signification de l'opération d'affectation. Avec une sémantique de valeur, la signification de l'affectation :

X := Y;

est de transférer le contenu de X (sa valeur) dans Y. Une modification apportée par une partie du programme à X n'affecte pas les autres duplications (comme Y). La comparaison d'égalité n'implique que l'égalité des valeurs, que ces valeurs soient contenues dans un même objet ou non.

Nous parlerons de sémantique de référence chaque fois que la valeur abstraite est un identifiant : type accès, indice de tableau, ou toute autre forme de nommage dont les différentes duplications se réfèrent en fait au même contenant. La signification de l'affectation :

X := Y;

devient : X et Y désignent le même objet. Toute modification apportée par un élément du programme au contenu de l'objet désigné par X sera donc visible (mais pas forcément immédiatement) à tous ceux qui utilisent des copies de la référence à l'objet (comme Y). La comparaison d'égalité entre deux variables aura la signification que les deux variables désignent le même objet (ce qui impliquera évidemment l'égalité des contenus). En revanche, deux objets peuvent être trouvés différents même si les contenus sont identiques.

Les types numériques offrent typiquement une sémantique de valeur, alors que les objets implémentés par un type accès offrent une sémantique de référence. Mais il ne faut pas restreindre cette différence à la simple alternative «pointeur ou pas pointeur», encore moins à la question de passage par adresse ou par valeur. Une chaîne de caractères représentant un nom de ficher a typiquement une sémantique de référence! Il est également possible d'obtenir une sémantique de valeur sur un objet abstrait représenté par un type accès si l'on utilise un type limité muni d'une redéfinition de l'égalité et d'un sous-programme de recopie. Le choix entre type privé et type limité sera d'une grande importance pour renforcer le respect de la sémantique voulue.

La spécification de tout type de donnée abstrait doit explicitement mentionner s'il est à sémantique de valeur ou de référence. En aucun cas, un type ne doit fournir de sémantique mixte, comme dans l'exemple suivant :

type Info is ...
type Référence is access Info;
type Valeur is ... ;
type Sémantique_Mixte is     À ne pas faire !!
	record
		Champ_1 : Référence;
		Champ_2 : Valeur;
	end record;

Un tel objet offrirait une sémantique de référence pour son Champ_1 (c'est un pointeur) et une sémantique de valeur pour son Champ_2. Il n'est plus possible dans ce cas de décrire simplement la signification de l'affectation! Même avec une sémantique de référence, on peut vouloir transférer le contenu d'un objet dans un autre. Un type de données à sémantique de référence doit alors disposer d'une fonction de recopie différente de l'affectation. Rappelons qu'Ada fournit désormais un puissant outil de contrôle du mécanisme d'affectation : un type dérivé du type prédéfini Controlled peut définir une opération, appelée Adjust, qui est appelée automatiquement après toute affectation à une variable de ce type. Il est ainsi possible de créer un type à sémantique de valeur, même si l'implémentation utilise des pointeurs, car l'opération Adjust peut forcer une duplication de la valeur de l'objet.

Il existe également une opération Initialize, appelée lors de la création de l'objet, et une procédure Finalize, appelée lors de la destruction de l'objet; cette dernière permet de récupérer l'espace mémoire alloué.

Remarquons que la distinction entre sémantique de valeur et sémantique de référence n'est importante que pour les types de données abstraits; les machines abstraites étant par définition uniques, l'affectation n'est pas définie pour elles. Bien que théoriquement rien ne l'impose, les gestionnaires de données doivent absolument être à sémantique de référence. Il serait évidemment désastreux du point de vue de l'efficacité, mais surtout au niveau du principe, d'avoir une sémantique de valeur (ce qui n'empêche pas, comme nous l'avons noté ci-dessus, de fournir une fonction de copie de la structure). En revanche, les éléments gérés par la structure de donnée pourront l'être soit sous forme de valeur (par exemple une liste de valeurs), soit sous forme de référence (par exemple une liste de pointeurs sur des objets). Cette distinction doit faire partie de la définition des propriétés de la structure de donnée.

À noter enfin que la valeur (interne) d'un type de donnée à sémantique de référence est un identifiant d'objet : par conséquent, une instanciation d'un gestionnaire de données gérant des valeurs avec un type à sémantique de référence sera en fait une structure de données manipulant des références. L'instanciation d'une structure de données gérant des références avec un type à sémantique de référence sera une structure manipulant des «références doubles». Ceci peut arriver, mais doit être en général évité, car leur manipulation est délicate à maîtriser, et il est difficile d'en garantir une sémantique claire.

  1. Ce point a été identifié par M. Gauthier, dont on trouvera les travaux dans [Gau94].

Tout n'est pas objet[1]

modifier

L'approche objet présente de nombreux avantages, mais cela ne signifie pas nécessairement qu'il faille tout modéliser en termes d'objets. Si l'entité du monde réel que l'on considère se résume à une suite d'actions, alors il est tout à fait souhaitable d'utiliser une approche structurée. Dans une conception orientée objet, ceci se produit dans deux cas particuliers importants : le premier et le dernier niveaux d'analyse.

Le premier niveau correspond à ce que l'on appelle généralement le programme principal. On aura défini les objets qui composent la représentation du domaine de problème, mais le programme principal est chargé d'animer, de faire vivre ces objets en appelant leurs fonctionnalités dans un certain ordre. Son analyse fait donc logiquement appel à la programmation structurée. De même, lorsqu'il s'agit de coder les corps des opérations des objets, il faudra bien utiliser une suite d'instructions du langage de programmation : là encore, une analyse en programmation structurée est appropriée.

On notera que pour un programme pas trop complexe, il n'existe pas de niveau intermédiaire entre le premier et le dernier niveaux : on se retrouve donc avec une analyse entièrement structurée. Autrement dit, l'approche objet ne renie pas la programmation structurée, mais la considère plutôt comme un cas limite «dégénéré» pour des programmes simples[2].

  1. Titre obligeamment emprunté à un article de M. Gauthier [Gau92].
  2. Ce qui explique qu'on ait pu utiliser si longtemps – et avec succès – la programmation structurée, jusqu'à l'explosion de complexité des logiciels que nous connaissons actuellement.

Le choix du critère vertical

modifier

Il existe encore un problème important que nous avons laissé en suspens : le choix du critère de décomposition verticale. La notion d'abstraction d'objets du monde réel nous fournit un critère de décomposition horizontale nous permettant de diviser le problème en un ensemble d'objets, représentés par des types de données abstraits (ou des machines abstraites). Cependant, dès que le projet atteint une taille importante, il devient nécessaire d'organiser les objets à plusieurs niveaux. Saisir à la fois tous les aspects d'un objet serait trop complexe; il convient donc d'organiser la décomposition des objets.

Il existe plusieurs façons de faire qui, tout en partageant la même notion d'objet en tant que critère de décomposition horizontale, diffèrent par le critère de décomposition verticale choisi. Il n'y a donc pas une, mais des méthodes orientées objet. Ce point est rarement évoqué et conduit à d'innombrables discussions entre les partisans de telle ou telle méthode, persuadés de détenir seuls la «vraie» orientation objet.

Dans les chapitres suivants, nous présenterons plusieurs de ces méthodes. Les principales utilisent soit la composition comme critère vertical, soit la classification. D'autres organisations sont possibles, et rien ne dit que toutes les façons de faire aient déjà été publiées.

L'approche par composition

modifier

Principes de la méthode

modifier
  1. Notion de niveaux d'abstraction
  2. Il serait faux de croire qu'il existe un ensemble unique de valeurs et d'opérations permettant de caractériser entièrement une abstraction. Tout dépend du point de vue ou, si l'on veut, de l'utilisation que l'on veut faire de l'abstraction. Une voiture comporte un moteur; ce qui le caractérise (pour l'utilisateur moyen), c'est sa cylindrée, le carburant utilisé, sa puissance et sa vitesse maximale... plus bien entendu les différentes commandes permettant de le faire fonctionner, et le fait qu'il entraîne la voiture. Le constructeur aura une vue très différente du même moteur : il s'agira pour lui avant tout d'un ensemble de pièces détachées, qu'il faut assembler dans un ordre donné. L'ingénieur responsable de la conception du moteur le verra à son tour comme un ensemble de pièces métalliques de coefficients de dilatation différents, dont la taille change avec la température et qu'il s'agit d'assembler de façon que l'ensemble n'explose pas en service normal. Laquelle de ces trois vues est la bonne? Aucune, et toutes. Ce sont trois abstractions différentes du même objet. De plus, ces abstractions sont hiérarchisées : elles vont de la vue la plus générale vers la plus élémentaire, chaque niveau ignorant les détails d'implémentation de la vue inférieure. Nous ne saurions trop insister sur le fait que cette hiérarchisation en niveaux d'abstraction non seulement est naturelle, mais que sans elle la vie de tous les jours serait impossible. Si seuls les gens capables de comprendre son fonctionnement interne pouvaient allumer un téléviseur, certaines émissions auraient moins d'audience... Non seulement la hiérarchie des niveaux d'abstraction est partout, mais souvent un problème ne se résoudra que s'il est traité au niveau approprié. Par exemple, vous pouvez écrire :
    I := 1;
    

    et vous pensez : «La variable I prend la valeur 1.» Il s'agit bien entendu d'une abstraction : si vous ouvrez un ordinateur vous ne verrez rien qui ressemble à la notion de «variable I». En fait, le compilateur remplacera cette instruction par la suite d'instructions assembleur :

    MOV A, #1
    MOV I, A

    Beaucoup de programmeurs pensent que c'est ce qui se passe «pour de vrai» dans l'ordinateur (et donc estiment que l'assembleur est le plus «vrai» des langages). Faux! La notion d'instruction machine n'est à son tour qu'une abstraction : c'est un point d'entrée dans la mémoire de microprogrammes du processeur. Et même les micro-instructions ne sont que des suites de bits qui ouvrent et qui ferment des portes logiques... Mais bien entendu, la notion de porte logique n'est, elle encore, qu'une abstraction (commode) pour désigner un ensemble de transistors arrangés d'une certaine façon... On pourrait continuer ainsi jusqu'au niveau des atomes et des électrons individuels. Si maintenant le programme contient une erreur, par exemple si vous auriez dû écrire :

    I := 0;
    

    cela se traduira bien par le fait que le nombre d'électrons emprisonnés dans le puits quantique d'une jonction de transistor faisant partie d'une mémoire ne sera pas ce qu'il devrait être. Vous n'allez pas pour autant chasser les erreurs de programmation au microscope électronique! Inversement, si un circuit d'un ordinateur est en panne, il sera très difficile de l'identifier au moyen d'un programme de haut niveau : seul l'analyseur logique de circuits permettra de le mettre en évidence.

    Retenons que tous les problèmes de l'existence sont structurés en niveaux d'abstraction; que pour chaque problème à résoudre, il existe un niveau d'abstraction approprié à sa solution, et que ni les niveaux inférieurs, ni les niveaux supérieurs ne seront satisfaisants. Et que donc en ce qui concerne les problèmes purement informatiques, la bonne définition des niveaux d'abstraction est ce qu'il y a de plus fondamental dans l'architecture d'un projet.

  3. La COO par composition
  4. Dans cette méthode, la décomposition verticale s'effectue par niveaux d'abstraction de plus en plus élémentaires; les objets sont organisés en strates correspondant chacune à un niveau d'abstraction différent. Un niveau d'abstraction (donc un module) définira toutes les propriétés abstraites d'un objet, pour une certaine vue. On passera au niveau suivant (définition de sous-modules) en effectuant un «zoom» sur l'objet pour en voir les détails : au lieu de considérer l'objet comme un tout, on s'intéressera alors à l'ensemble des objets de plus bas niveau qui le composent.
     
    Figure 15 : Plans d'abstraction
    Figure 15 : Plans d'abstraction

    Ce mécanisme autorise le développement d'un projet par plans d'abstraction successifs (Figure 15). À un moment donné, on travaille sur une vue concrète (celle que l'on réalise) qui nécessitera la définition des vues abstraites (spécifications) du niveau inférieur. Un plan d'abstraction comprendra donc l'implémentation de l'abstraction considérée (les boîtes grisées de la figure 16), et les spécifications des niveaux immédiatement inférieurs. Les flèches verticales expriment la dépendance d'une implémentation à sa propre spécification, alors que les flèches horizontales expriment la dépendance d'une implémentation aux spécifications des objets qui la composent.

    La notion de plan d'abstraction est renforcée par le mécanisme de compilation Ada : un plan sera constitué du corps d'une unité et des spécifications nommées par lui dans des clauses with. Il sera alors possible de faire faire certaines vérifications de cohérence par le compilateur. En effet, les objets informatiques étant proches des objets du monde réel, et le compilateur Ada d'une rigueur extrême sur tout ce qui concerne les cohérences de type, on constate expérimentalement qu'une incohérence dans la définition d'un objet entraîne souvent une incohérence de type détectée par le compilateur. On peut ainsi utiliser le compilateur comme moyen de vérification de la conception, moyen qui sera d'autant plus efficace que le programmeur aura été plus rigoureux dans la définition de ses types; on obtient alors une «prime à la rigueur» qui n'est pas un des effets les moins intéressants du couplage de la méthode par composition au langage Ada.

    Considérons, pour illustrer cette démarche, la notion de fichier indexé. Qu'est-ce qu'un fichier indexé? Pour la vue «utilisateur», de plus haut niveau, c'est un ensemble de données dont les éléments sont accessibles au moyen d'une clé, le tout constituant un objet unique, ce que nous avons symbolisé (Figure 16) par la présence d'une fonctionnalité Read (lecture) et d'une fonctionnalité Seek (recherche au moyen d'une clé). À un plus grand niveau de détail (premier niveau d'implémentation), on pourra voir que ce fichier (logique) est constitué, par exemple, d'un fichier de données et d'un fichier d'index, constitués de fichiers directs au sens d'Ada : chaque élément est accessible par un Read aléatoire. Noter qu'il n'y a qu'une seule flèche entre l'implémentation de Fichier_Indexé et la spécification de Fichier_Direct, bien que l'on utilise deux fichiers directs : la signification de celle-ci est que la notion de fichier indexé utilise, pour son implémentation, la notion de fichier direct. Elle exprime une dépendance logique, non une relation d'inclusion. À un plus bas niveau d'abstraction, chacun de ces fichiers est implémenté au moyen de la notion de fichier du système d'exploitation, qui eux-mêmes se résument au niveau physique à un ensemble de secteurs sur un disque dur... Ces différentes vues du fichier sont indépendantes : une autre implémentation du fichier indexé pourrait utiliser d'autres mécanismes. Aucune des propriétés spécifiques des fichiers d'index ou de données n'est transmise au fichier indexé, mais le comportement global du fichier indexé résulte de l'assemblage de ses différents composants.

     
    Figure 16 : Représentation d'un fichier indexé en composition
    Figure 16 : Représentation d'un fichier indexé en composition

    Comme dans tout développement de programme, cette méthode conduit à une décomposition en modules; mais ici, un module contient tous les aspects d'un objet particulier. Ceci permet un regroupement des fonctionnalités utiles à haut niveau en occultant tous les détails d'implémentation : on réalise ainsi l'indépendance entre la vue abstraite d'un objet et son implémentation, ainsi qu'entre objets utilisant des sous-objets communs. On ne définit que des relations de «prestataire de service» à «utilisateur», ou des relations entre niveaux d'abstraction différents. La topologie des programmes construits selon cette méthode se présente comme des graphes généraux acycliques et non transitifs : toute modification d'un module n'affecte potentiellement que les niveaux directement inférieurs (et non pas tous les niveaux inférieurs comme en programmation structurée), et réciproquement tout niveau ne connaît que le niveau qui lui est immédiatement supérieur. La complexité des dépendances diminue considérablement, et les relations entre modules deviennent beaucoup plus faciles à gérer.

Exemple en composition

modifier

Nous voulons réaliser un «cahier de comptes» pour gérer une comptabilité personnelle. Nous allons nous appuyer sur ce que nous connaissons bien, le cahier que nous gérions auparavant à la main. Qu'est-ce donc qu'un cahier de comptes? C'est un ensemble ordonné d'écritures. Nous voyons donc apparaître deux objets : un gestionnaire de données de type machine abstraite qui est le cahier lui-même, et un type de donnée abstrait qui est la notion d'écriture.

Le cahier doit offrir les fonctionnalités habituelles d'une liste séquentielle (insérer et supprimer des éléments, parcourir la liste, etc.). Il possède une propriété supplémentaire : les écritures doivent rester triées par rapport à un ordre courant. Nous imposons donc que la fonction Insérer ajoute toujours une écriture à la bonne place. Mais pour cela, il faut pouvoir comparer plusieurs écritures en fonction d'un critère courant. Comment définir ce critère? Le plus simple est de le définir par rapport à une fonction de comparaison. Nous devons également fournir un point d'entrée pour changer de critère de comparaison. Comme le cahier est évidemment un gestionnaire de données, nous pouvons considérer qu'il existe toujours une position courante, et que la suppression d'une écriture porte toujours sur l'écriture courante. Nous pouvons décrire cette vue du cahier comme :

with Les_Ecritures; 
package Cahier_Compta is
	use Les_Ecritures;
	procedure Insérer (L_Ecriture : Ecriture);
	procedure Supprimer;
	function  Ecriture_Courante return Ecriture;
	type Déplacement is (Début, Fin, Précédent, Suivant);
	procedure Changer_Position (Vers : Déplacement);
	function Début_Cahier		return Boolean;
	function Fin_Cahier		return Boolean;
	Erreur_Déplacement : exception;
	type Fonction_Ordre is 
		access function (X, Y : Ecriture) return Boolean;
	procedure Changer_Ordre (Comparaison: Fonction_Ordre);
end Cahier_Compta;

Une écriture comprend plusieurs éléments : une date d'opération, une date de valeur, un texte descriptif et un montant. En termes d'opérations, il faut pouvoir éditer une écriture, c'est-à-dire permettre à l'utilisateur de modifier son contenu. Enfin, nous devons fournir des fonctions de comparaison entre écritures. Nous pouvons décrire ainsi la vue abstraite des Ecriture :

with Ada.Calendar; 
package Les_Ecritures is
	type Argent is delta 0.01 digits 10;
	use Ada.Calendar;
	type Ecriture is
		record
			Date_Opération : Time;
			Date_Valeur    : Time;
			Descriptif     : String(1..20);
			Montant        : Argent;
		end record;
	procedure Editer (L_Ecriture : Ecriture);
	function Date_Op_Inférieur (Gauche, Droite : Ecriture)
		return Boolean;
	function Date_Val_Inférieur (Gauche, Droite : Ecriture)
		return Boolean;
	function Montant_Inférieur (Gauche, Droite : Ecriture)
		return Boolean;
end Les_Ecritures;

Enfin, un programme de ce type n'a de sens que s'il est interactif. Il nous faut donc représenter l'interface utilisateur. Une interface digne des environnements modernes sortirait du cadre de cet exemple[1], mais nous pouvons toujours en faire une abstraction simplifiée : l'interface est ce qui permet au programme de recevoir des ordres de la part de l'utilisateur. Ces ordres sont des entités élémentaires. Il nous faudrait aussi différents moyen d'envoyer des données à l'utilisateur. À ce niveau, nous ne nous préoccupons pas directement des problèmes d'implémentation, aussi nous en tiendrons-nous au niveau logique : par exemple, nous pouvons prévoir d'envoyer des messages d'erreur à l'écran. Nous sommes trop haut dans les niveaux d'analyse pour spécifier des choses telles que faire apparaître une fenêtre avec un beau point d'exclamation pour signaler un problème : nous prévoyons simplement d'envoyer des messages d'erreur, indépendamment de toute représentation. Nous pouvons exprimer ceci comme :

package Interface_Utilisateur is
	type Ordre is 
		(Sortir,
		 Avancer,	Reculer,		Aller_Début,	Aller_Fin,
		 Insérer,	Modifier,		Supprimer,
		 Trier_date_valeur,	Trier_date_opération, 
		 Trier_montant);
	function Ordre_Suivant return Ordre;
	procedure Signaler_Erreur (Texte : String);
end Interface_Utilisateur;

Ayant ainsi défini les briques de base, il ne nous reste plus qu'à les assembler pour vérifier qu'elles nous permettent de réaliser le comportement désiré :

with Interface_Utilisateur, Les_Ecritures, Cahier_Compta;
procedure Compta_Perso is
	use Cahier_Compta, Interface_Utilisateur, Les_Ecritures;
begin
	Cahier_Compta.Changer_Ordre (Date_Val_Inférieur'Access);
	loop
		case Ordre_Suivant is
		when Sortir =>
			exit;
		when Aller_Début =>
			Cahier_Compta.Changer_Position	(Début);
		when Aller_Fin =>
			Cahier_Compta.Changer_Position	(Fin);
		when Avancer =>
			if not Cahier_Compta.Fin_Cahier then
				Cahier_Compta.Changer_Position (Suivant);
			end if;
		when Reculer =>
			if not Cahier_Compta.Début_Cahier then
				Cahier_Compta.Changer_Position (Précédent);
			end if;	
		when Supprimer =>
			Cahier_Compta.Supprimer;
		when Insérer =>
			declare
				Nouvelle_Ecriture : Ecriture;
			begin
				Editer (Nouvelle_Ecriture);
				Cahier_Compta.Insérer (Nouvelle_Ecriture);
			end;
		when Modifier =>
			declare
				Ancienne : constant Ecriture 
				         := Cahier_Compta.Ecriture_Courante;
				Nouvelle : Ecriture := Ancienne;
			begin
				Editer (Nouvelle);
				if Nouvelle /= Ancienne then
					Cahier_Compta.Supprimer;
					Cahier_Compta.Insérer (Nouvelle);
				end if;
			end;
		when Trier_date_valeur =>
			Cahier_Compta.Changer_Ordre
				(Date_Val_Inférieur'Access);
		when Trier_date_opération =>
			Cahier_Compta.Changer_Ordre
				(Date_Op_Inférieur'Access);
		when Trier_montant =>
			Cahier_Compta.Changer_Ordre
				(Montant_Inférieur'Access);
		end case;
	end loop;
end Compta_Perso;

Il manque bien sûr encore de nombreuses fonctionnalités (à commencer par la sauvegarde du cahier sur fichier...), mais l'important est que nous avons mis en place une structure où le rôle de chaque module est clairement identifié : tout ce qui concerne la définition et les propriétés des écritures prises individuellement se trouve dans le paquetage Les_Ecritures; tout ce qui concerne le cahier, c'est-à-dire la gestion de l'ensemble d'écritures, est dans Cahier_Compta; tout ce qui concerne les interfaces est dans Interface_Utilisateur. À partir de là, il est facile de rajouter des fonctionnalités en sachant toujours où intervenir.

L'autre point important est que cette structure nous permet de définir des comportements simples et des invariants : par exemple, Insérer ajoute toujours l'écriture au bon endroit compte tenu du critère de tri courant. Un phénomène très intéressant, qui s'est produit lorsque nous avons développé le projet réel d'où est tiré cet exemple, montre bien l'amélioration importante de sécurité apportée par cette indépendance. Nous avions une erreur dans l'algorithme de retri de Changer_Ordre qui conduisait à des écritures incorrectement ordonnées dans certains cas. Cependant, pour visualiser le cahier, le programme «sortait» une écriture du cahier pour une modification éventuelle, puis la «re-rentrait». À ce moment, le logiciel s'apercevait que l'écriture n'était plus à sa place, et la réinsérait au bon endroit. Autrement dit le logiciel se trouvait dans un état incorrect, mais le simple fait de parcourir le cahier faisait qu'il retournait spontanément à l'état correct. Le logiciel était donc en quelque sorte «autostable». Ce résultat fort rassurant a été obtenu grâce aux principes d'indépendance temporelle et de méfiance réciproque : chaque fonctionnalité ne sait rien ni de qui l'appelle, ni en quelles circonstances. Du coup, chacun assure sa propre sécurité... et corrige éventuellement les erreurs de son voisin. Un tel comportement, qui ne peut être obtenu que par la stricte encapsulation permise par la composition, renforce la confiance que l'on peut avoir quant au bon comportement du logiciel, même face à des événements inattendus.

  1. Dans le programme réel qui nous a inspiré cet exemple, environ 80% du code est lié à l'interface utilisateur!

L'approche par classification

modifier

À l'origine de cette approche se trouve l'émergence des langages orientés objet (LOO) [Mas89], dont la classification constitue le cadre méthodologique, en particulier pour maîtriser le mécanisme d'héritage qui les caractérise; on a pris l'habitude d'appeler programmation orientée objet (POO) la programmation avec ces langages. De ce fait, la classification est souvent présentée dans la littérature comme la seule méthode orientée objet. Nous avons déjà vu que l'approche par composition permettait de développer des systèmes «orientés objet» sans nécessiter le recours au mécanisme d'héritage, et nous ne saurions trop insister sur le fait que le concept d'objet couvre un domaine beaucoup plus vaste que celui de la seule POO.

Principes de la méthode

modifier

Dans cette méthode, le critère de décomposition verticale est la classification (au sens de la classification des espèces de Linné [Lin35]). Les objets sont regroupé en classes, elles-mêmes décomposées en sous-classes plus précises, etc[1]. Pour éviter toute confusion, on appelle instance un objet particulier appartenant à une classe.

Par exemple, on regroupera dans la classe Insecte les propriétés communes à tous les insectes. Par la suite, on pourra définir des sous-classes comme Volants ou Rampants, ne contenant respectivement que ce qui est spécifique aux insectes volants (ou rampants), mais commun à tous les insectes volants (ou rampants). Les Volants peuvent à leur tour se décomposer en Mouches, Guêpes, etc., suivant l'arborescence de la figure 17. La guêpe qui vient de vous piquer est une instance de la classe des Guêpes.

 
Figure 17 : Classification d'insectes
Figure 17 : Classification d'insectes

Bien entendu, une mouche est un insecte volant, qui est lui-même un insecte tout court. Une Mouche doit donc également posséder les propriétés des Volants et des Insectes : les propriétés des superclasses doivent être transmises à leurs descendants. Ce mécanisme est appelé héritage, puisqu'une classe enfant hérite des propriétés de ses ancêtres. On distingue classiquement l'héritage simple (toute classe n'est enfant que d'une seule superclasse) qui conduit à des programmes dont la topologie est un arbre, et l'héritage multiple (toute classe peut avoir plusieurs superclasses) qui conduit à des programmes dont la topologie est un graphe général acyclique. Nous reviendrons plus loin sur la question de l'héritage multiple. Au fur et à mesure que l'on descend dans l'analyse, les objets possèdent de plus en plus de propriétés (celles dont ils ont hérité, plus celles qui sont particulières à la sous-classe), applicables à un nombre de plus en plus restreint d'instances. La descente dans l'analyse conduit donc simultanément à un enrichissement et à une spécialisation des classes.

Comme nous l'avons vu au paragraphe définition, une caractéristique de l'orientation objet en général est l'encapsulation, regroupement dans un même module de structures de données et de structures de programme. Dans le vocabulaire de la classification, on a pris l'habitude d'appeler attributs les structures de données liées à un objet, et méthodes les structures de programme (opérations) associées.

  1. Tous les objets appartenant à une classe possèdent les mêmes propriétés, ils forment donc une classe d’équivalence au sens mathématique - d’où le nom.

Mécanismes

modifier

La mise en application de la classification nécessite des mécanismes spéciaux au niveau du langage de programmation. On qualifie de «langage orienté objet» un langage offrant ces mécanismes. Nous allons présenter ceux offerts par Ada; d'autres langages orientés objet offrent des fonctionnalités similaires, mais non strictement équivalentes. Nous les comparerons à la fin de ce chapitre.

  1. Types étiquetés
  2. En Ada, les mécanismes que nous allons présenter ne sont disponibles qu'avec les types étiquetés (tagged), c'est-à-dire des types article (record) déclarés avec le mot réservé tagged. Ceci n'est pas une restriction, mais une protection supplémentaire. Nous verrons en effet que l'héritage apporte une plus grande souplesse d'évolution, au prix d'un certain affaiblissement du contrôle (statique) des types. Les utilisateurs qui ne souhaitent pas utiliser ces mécanismes (comme par exemple les utilisateurs temps réel) ont la garantie, si le mot tagged n'apparaît pas dans leurs programmes, de bénéficier de la même qualité de vérification statique qu'en Ada 83. Notons que, comme pour un type non étiqueté, la définition d'un type étiqueté n'inclut pas d'opérations; une classe complète (c'est-à-dire un type de donnée abstrait muni d'opérations) se réalise en Ada sous la forme d'un paquetage comportant la définition d'un type étiqueté et des sous-programmes portant sur ce type. Les attributs de la classe seront alors les champs du type étiqueté, et les méthodes les sous-programmes associés. Nous allons présenter progressivement les concepts de la classification en utilisant un exemple très classique, celui d'objets graphiques, figures géométriques destinées à être représentées sur un écran. Tous les objets graphiques possèdent un attribut en commun, leur position sur l'écran, et des méthodes pour les dessiner, les effacer et les déplacer. On peut définir la classe la plus générale comme :
    package Classe_Objet_Graphique is
    	type Coordonnée is range 1..1024;
    	type Distance   is range 0..1024;
    	type Objet_Graphique is tagged
    		record
    			X, Y : Coordonnée;
    		end record;
    	procedure Dessiner (Objet : Objet_Graphique);
    	procedure Effacer(Objet : Objet_Graphique);
    	procedure Déplacer (Objet : in out Objet_Graphique;
    	                   En_X   : in    Coordonnée;
    	                   En_Y   : in    Coordonnée);
    end Classe_Objet_Graphique;
    package body Classe_Objet_Graphique is
    	procedure Dessiner (Objet : Objet_Graphique) is
    	begin
    		null;
    	end Dessiner;
    	procedure Effacer(Objet : Objet_Graphique) is
    	begin
    		null;
    	end Effacer;
    	procedure Déplacer (Objet : in out Objet_Graphique;
    	                   En_X   : in    Coordonnée;
    	                   En_Y   : in    Coordonnée) is
    	begin
    		Objet := (En_X, En_Y);
    	end;
    end Classe_Objet_Graphique;
    
    Noter que nous faisons la différence au niveau du typage entre une Coordonnée qui représente une position absolue à l'écran, et une Distance qui est homogène à la différence de deux coordonnées. Nous aurons besoin de la Distance par la suite.

    Ceci définit une classe Objet_Graphique, munie des attributs X et Y et des méthodes Dessiner, Effacer et Déplacer. Avec cette définition de la classe, les coordonnées X et Y sont accessibles de l'extérieur (la définition figure dans la partie visible du paquetage). Nous fournissons cependant la méthode Déplacer pour modifier ces valeurs[1], car il s'agit d'une opération qui conceptuellement pourrait faire plus que simplement modifier les coordonnées. D'autre part, notre classe est trop générale à ce stade pour nous permettre de définir quoi que ce soit de précis pour les méthodes Dessiner et Effacer; ces dernières ne font donc rien (nous verrons plus loin une solution plus satisfaisante à ce problème).

    1. Nous reviendrons plus loin sur le problème du déplacement de l'objet.
  3. Extension de type et héritage; redéfinition de méthodes
  4. Nous voulons maintenant définir un objet Cercle. Un cercle est une sorte d'objet graphique, mais il est plus spécialisé : tous les objets graphiques ne sont pas des cercles. Un cercle possède, en plus d'une position, une autre grandeur caractéristique : son rayon. Nous pouvons définir un type Cercle comme une extension (un enrichissement) du type Objet_Graphique :
    type Cercle is new Objet_Graphique with
    	record
    		Rayon : Distance;
    	end record;
    

    Le type Cercle est dérivé du type Objet_Graphique; tous les attributs définis pour le type Objet_Graphique sont disponibles. Il disposera également (aura hérité) de méthodes Dessiner, Effacer et Déplacer dont l'implémentation, fournie par le compilateur, consistera à appeler les méthodes correspondantes définies pour Objet_Graphique en convertissant le paramètre de Cercle en Objet_Graphique.

    Ce mécanisme n'est pas nouveau; il existait déjà en Ada 83 pour les types dérivés non étiquetés, mais seuls les types étiquetés permettent d'enrichir le type en rajoutant des composants (clause with record ... end record).

    On voit immédiatement l'intérêt de cette approche : en cas de redéfinition des caractéristiques de l'Objet_Graphique, tous les types dérivés sont remis à jour sans se préoccuper de rien. Si nous souhaitions par exemple ajouter par la suite un nouvel attribut à Objet_Graphique (une couleur par exemple), aucun changement ne serait nécessaire dans Cercle ni dans aucun objet qui serait dérivé directement ou indirectement de Objet_Graphique.

    Les méthodes définies dans Objet_Graphique ne sont cependant pas appropriées pour un Cercle. Il faut effectivement faire le dessin, mais Déplacer ne fait que changer les attributs sans mettre à jour l'écran. Il arrivera ainsi fréquemment que les méthodes dont hérite un type ne fournissent pas la bonne fonctionnalité : le type dérivé doit définir sa propre façon de faire, sa méthode justement, pour réaliser la fonctionnalité abstraite. C'est pourquoi on peut redéfinir des sous-programmes dont un type a hérité. Par exemple :

    type Cercle is new Objet_Graphique with
    	record
    		Rayon : Distance;
    	end record;
    procedure Dessiner (Objet : Cercle) is
    begin
    	Instructions pour dessiner le cercle
    end Dessiner;
    procedure Effacer(Objet : Cercle) is
    begin
    	Instructions pour effacer le cercle
    end Effacer;
    procedure Déplacer (Objet : in out Cercle
                        En_X  : in     Coordonnée;
                        En_Y  : in     Coordonnée) is
    begin
    	Effacer(Objet);
    	Objet := (En_X, En_Y, Objet.Rayon);
    	Dessiner(Objet);
    end Déplacer;
    

    Les règles normales de la surcharge permettent de déterminer la méthode appelée : un appel à Déplacer avec un paramètre de type Objet_Graphique appellera la méthode d'origine, alors que si le paramètre est de type Cercle, c'est la procédure redéfinie qui sera appelée.

  5. Classes, polymorphisme et liaison dynamique
  6. Imaginons maintenant que nous ayons besoin d'un service assez général; par exemple, une procédure qui dessinerait deux fois un objet, le second légèrement décalé par rapport au premier. Il n'y a pas de raison de rattacher ce service à une classe particulière : c'est un besoin utilisateur, non une propriété fondamentale de l'abstraction des objets graphiques dont hériteraient tous les descendants. Cependant, il faudrait pouvoir l'appliquer à tous les objets graphiques; autrement dit, nous voulons une opération qui porte sur la classe des objets graphiques. C'est possible grâce à l'attribut 'Class qui s'applique à un type pour désigner la classe associée au type, c'est-à-dire le type lui-même ainsi que l'ensemble de tous les types dérivés (directement ou indirectement) du type concerné. Noter la distinction faite entre, par exemple, le type Objet_Graphique (qui ne contient que les objets déclarés avec ce type) et la classe Objet_Graphique'Class qui inclut tous les objets du type Objet_Graphique, ou de types dérivés d'Objet_Graphique, tels que Cercle. Pour bien marquer cette différence, on utilisera l'appellation type spécifique pour parler d'un type particulier à l'intérieur d'une classe, et type à l'échelle de classe[1] pour désigner les types «classe» couvrant plusieurs types spécifiques différents. Nous pouvons maintenant définir la procédure ainsi :
    procedure Dédoubler(L_Objet : in Objet_Graphique'Classe) is
    	Copie : Objet_Graphique'Class := L_Objet;
    begin
    	Dessiner (Copie);
    	Déplacer (Copie, En_X => L_Objet.X +10,
    	                 En_Y => L_Objet.Y +10);
    	Dessiner (Copie);
    end Dédoubler;
    

    Le paramètre n'est plus défini comme appartenant à un type spécifique, mais à une classe; cette procédure peut donc être appelée en fournissant un paramètre réel appartenant à n'importe quel type de la classe, c'est-à-dire aussi bien au type Objet_Graphique qu'à Cercle ou à n'importe quel autre type dérivé, directement ou indirectement, de Objet_Graphique. On exprime ainsi que l'algorithme est applicable à tous les objets qui sont des Objet_Graphique[2].

    Dans la procédure Dédoubler, nous avons un paramètre formel dont le type spécifique est a priori inconnu : il dépend de l'objet qui sera passé au sous-programme au moment de l'appel. De même, nous avons une variable locale[3] (Copie) déclarée avec un type à l'échelle de classe; une telle variable doit être initialisée, et c'est le type spécifique de la valeur initiale qui détermine le type de la variable. On parle alors de polymorphisme, puisque ces variables peuvent prendre plusieurs types, plusieurs formes, à l'exécution. Ada autorise également des pointeurs sur classe :

    type Ptr_Objet is access Objet_Graphique'Class;
    

    Un tel pointeur peut désigner n'importe quel objet de la classe, c'est-à-dire de type Objet_Graphique, Cercle, Rectangle, ou de n'importe quel autre type dérivé de Objet_Graphique. Nous verrons plus loin un exemple d'utilisation des pointeurs sur classe pour réaliser des listes hétérogènes.

    La procédure Dédoubler pose cependant une question importante. Puisqu'elle va pouvoir s'appliquer à tout objet graphique, comment savoir au moment de la compilation s'il faut appeler la méthode Dessiner d'origine ou celle redéfinie par Cercle? On ne le peut pas. Ce n'est qu'au moment de l'appel de Dessiner par Dédoubler que l'on saura s'il faut appeler la méthode Dessiner définie sur Cercle ou celle définie sur Rectangle. On parlera alors de liaison dynamique (dynamic binding) puisque le travail de mise en relation des sous-programmes à appeler ne peut être effectué dès la compilation, mais seulement à l'exécution, pour chaque appel. On peut comprendre ce mécanisme comme une résolution de surcharge qui aurait lieu à l'exécution; c'est le type de l'instance (le paramètre réel fourni au sous-programme Dédoubler) qui détermine le sous-programme effectivement appelé. Ceci implique de garder à l'exécution la trace de son type avec chaque valeur. Cette «marque d'origine» est l'étiquette, le fameux tag, ce qui explique que cette façon de programmer ne soit possible qu'avec des types étiquetés.

    La liaison dynamique va nous permettre de résoudre élégamment le problème de la procédure Déplacer. Jusqu'à présent, nous devions la redéfinir pour chacun des types dérivés, car celle dont nous héritions ne faisait que changer la position. On pourrait penser définir le Déplacer d'Objet_Graphique comme :

    procedure Déplacer (Objet : in out Objet_Graphique;
                        En_X  : in     Coordonnée;
                        En_Y  : in     Coordonnée) is
    begin
    	Effacer(Objet);
    	Objet := (En_X, En_Y);
    	Dessiner (Objet);
    end Déplacer
    

    mais ce ne serait pas suffisant, car le Déplacer dont hériteraient les classes dérivées continuerait à appeler les méthodes Dessiner et Effacer définies pour des Objet_Graphique. Or il est évident que pour déplacer un cercle, il faut redessiner un cercle, et que pour déplacer un rectangle, il faut redessiner un rectangle! Autrement dit, il faudrait que dans Déplacer, les appels à Dessiner et Effacer s'effectuent de façon dynamique, en fonction du type effectif du paramètre réel. C'est possible en faisant «oublier» au compilateur le type effectif du paramètre, c'est-à-dire en le convertissant vers la classe :

    procedure Déplacer (Objet : in out Objet_Graphique;
                        En_X  : in     Coordonnée;
                        En_Y  : in     Coordonnée) is
    begin
    	Effacer(Objet_Graphique'Class (Objet));
    	Objet := (En_X, En_Y);
    	Dessiner (Objet_Graphique'Class (Objet));
    end Déplacer
    

    Dans les appels ci-dessus, le type effectif du paramètre de l'appel à Effacer et Dessiner n'est plus connu; le compilateur ira donc consulter l'étiquette associée au paramètre formel Objet pour décider quelle méthode appeler. Nous obtiendrons bien la liaison dynamique avec la méthode définie pour le paramètre réel (celui fourni par l'appel).

    En résumé, il suffit de se rappeler que le type spécifique de l'expression figurant dans un appel de sous-programme détermine le sous-programme appelé. Si ce type n'est pas connu à la compilation (c'est-à-dire si l'expression est d'un type à l'échelle de classe), alors il y a liaison dynamique et le sous-programme appelé est déterminé par l'étiquette du paramètre.

    1. Traduction lourde, mais fidèle, de class wide type.
    2. Remarquer l'affaiblissement du typage lié à cette façon de faire : une même procédure est appelable avec des paramètres de types différents.
    3. En travaillant sur une copie du paramètre formel, nous pouvons garantir que cette procédure ne modifie pas la position de l'objet passé en argument.
  7. Liaison avec le parent
  8. Lorsque l'on redéfinit des méthodes héritées, il est fréquent que l'on souhaite simplement ajouter certains traitements au traitement «de base» fourni par le type parent. Il faudra donc appeler la méthode définie par le parent depuis le corps d'une méthode d'un enfant. Ceci se fait en Ada par le même mécanisme que celui vu précédemment : il suffit d'appeler le sous-programme voulu en effectuant une conversion vers le type qui gouverne la méthode que l'on souhaite appeler, puisque seule la cohérence des types détermine le sous-programme appelé. Par exemple, nous pouvons avoir un objet Cercle_Pointé, analogue à Cercle, mais où l'on marque le centre du cercle par un point. Nous pouvons le définir ainsi :
    type Cercle_Pointé is new Cercle with null record;
    procedure Dessiner (Objet : Cercle_Pointé) is
    begin
    	Dessiner (Cercle (Objet));
    	-- Dessiner le centre
    end Dessiner;
    procedure Effacer  (Objet : Cercle_Pointé) is
    begin	
    	-- Effacer le centre
    	Effacer (Cercle (Objet));
    end Dessiner;
    

    Nous créons un nouveau type Cercle_Pointé, mais comme il ne comporte pas de nouveau champ, nous devons le signaler par la clause with null record. Il n'est pas nécessaire de récrire la procédure de dessin du cercle : l'avantage de la POO est qu'on ne recode que la différence entre le nouveau comportement et celui que l'on possédait déjà. Par conséquent, le Dessiner d'un Cercle_Pointé appelle le Dessiner de Cercle grâce à une conversion de type; de même pour Effacer. On peut comprendre cet appel comme signifiant : «Dessiner ce Cercle_Pointé en le considérant comme un Cercle.» Noter au passage que le type figurant dans l'appel est connu statiquement, il n'y a donc pas de liaison dynamique. Remarquer également que dans le cas d'une hiérarchie complexe, n'importe quel enfant peut choisir d'appeler une des méthodes de n'importe lequel de ses ancêtres, simplement en convertissant le paramètre dans le type approprié.

  9. Types abstraits
  10. Il est évident que tout objet graphique doit pouvoir être dessiné à l'écran; c'est pourquoi nous avons défini les méthodes Dessiner et Effacer dans la classe Objet_Graphique. Nous avons dû leur donner des implémentations vides, car la classe Objet_Graphique est trop générale pour pouvoir décrire quoi faire. En fait, cela n'aurait aucun sens de définir un Objet_Graphique qui ne serait pas également quelque chose de plus précis : la classe de départ n'a d'intérêt que pour définir des sous-classes, non des instances. Nous pouvons exprimer cette contrainte en déclarant le type Objet_Graphique de la façon suivante :
    	type Objet_Graphique is abstract tagged
    		record
    			X, Y : Coordonnée;
    		end record;
    

    ce qui nous permet maintenant de définir les méthodes Dessiner et Effacer comme des procédures abstraites :

    	procedure Dessiner(Objet : Objet_Graphique) is abstract;
    	procedure Effacer (Objet : Objet_Graphique) is abstract;
    

    De tels sous-programmes ne sont pas définis effectivement (on ne fournit pas d'implémentation), ils ne servent qu'à marquer que les types dérivés d'Objet_Graphique devront obligatoirement redéfinir ces procédures. Comme il est nécessaire que les utilisateurs soient informés de cette propriété, on ne peut définir de sous-programmes abstraits d'un type étiqueté que si le type a lui-même été déclaré abstract. Le type Objet_Graphique, ainsi que les types dérivés qui n'auraient pas redéfini les sous-programmes abstraits, sont appelés types abstraits[1] : il est interdit de déclarer des objets ou des valeurs de ces types puisque toutes leurs propriétés ne seraient pas définies; ils ne peuvent servir qu'à dériver d'autres types.

    1. Attention : le terme «type abstrait» est défini par le langage et désigne un type muni de la clause is abstract, à ne pas confondre avec la notion générale de type représentant une abstraction d'une entité du monde réel, pour laquelle nous utilisons le terme «type de donnée abstrait».
  11. Dissimulation d'information
  12. Les attributs du type Objet_Graphique tel que défini jusqu'à présent sont totalement visibles. En général, on préférera contrôler l'accès aux attributs. Il faut pour cela en faire un type privé, mais bien entendu il est nécessaire de spécifier que même privé, le type est étiqueté, afin de pouvoir l'utiliser en tant que tel dans des dérivations ultérieures. Notre spécification de paquetage va donc devenir :
    package Classe_Objet_Graphique is
    	type Coordonnée is range 1..1024;
    	type Distance   is range 0..1024;
    	type Objet_Graphique is abstract tagged private;
    	procedure Positionner (Objet : in out Objet_Graphique;
    	                       En_X  : in     Coordonnée;
    	                       En_Y  : in     Coordonnée);
    	function X_courant (Objet : Objet_Graphique)
    		return Coordonnée;
    	function Y_courant (Objet : Objet_Graphique)
    		return Coordonnée;
    	procedure Déplacer	(Objet : in out Objet_Graphique;
    	                    En_X  : in     Coordonnée;
    	                    En_Y  : in     Coordonnée);
    	procedure Dessiner(Objet : Objet_Graphique) is abstract;
    	procedure Effacer (Objet : Objet_Graphique) is abstract;
    private
    	type Objet_Graphique is abstract tagged
    		record
    			X, Y : Coordonnée;
    		end record;
    end Classe_Objet_Graphique;
    

    La structure étant devenue cachée, nous avons fourni un constructeur (Positionner) et deux sélecteurs (X_Courant et Y_Courant) afin de permettre respectivement de donner une valeur aux coordonnées et de retrouver leurs valeurs courantes. Réciproquement, il est possible d'étendre un type de façon privée, c'est-à-dire en cachant à l'utilisateur le contenu de l'extension. Par exemple, Cercle pourrait être défini comme suit :

    with Classe_Objet_Graphique; use Classe_Objet_Graphique;
    package Classe_Cercle is
    	type Cercle is new Objet_Graphique with private;
    
    	procedure Dessiner (Objet : Cercle);
    	procedure Effacer  (Objet : Cercle);
    
    private
    	type Cercle is new Objet_Graphique with
    		record
    			Rayon : Distance;
    		end record;	
    end Classe_Cercle;
    

    Remarquer la spécification explicite des procédures Dessiner et Effacer : le compilateur sait ainsi que le paquetage va fournir ces sous-programmes, et que le type peut ne pas être abstrait.

  13. Facettes multiples
  14. Dans l'exemple que nous avons vu, la classe Cercle héritait des propriétés d'une seule classe, Objet_Graphique. Or il arrive parfois que l'on veuille considérer des objets complexes selon plusieurs vues, plusieurs facettes. Par exemple, on peut définir une classe Figure_Géométrique permettant d'obtenir diverses caractéristiques (périmètre, surface...) des objets géométriques. Il est bien certain qu'un cercle est un objet graphique, mais c'est également un objet géométrique. Il n'est ni nécessaire, ni souhaitable de rassembler ces deux notions dans une même classe, car elles sont orthogonales : les segments sont des objets graphiques tout à fait respectables, mais cela n'aurait pas de sens de parler de leur périmètre ni de leur surface. Il faudrait donc ajouter à Cercle les propriétés liées à la notion de Figure_Géométrique en plus de celles héritées de la classe Objet_Graphique; nous dirons que nous lui rajoutons une facette de figure géométrique. Ceci se fait facilement en Ada au moyen de la technique d'enrichissement de classe par des génériques. Nous pouvons écrire un générique destiné à fournir des propriétés géométriques à toute autre classe :
    with Grandeurs; -- Définit Longueur, Surface etc.
    generic
    	type Objet is abstract tagged limited private;
    package Géométrique is
    	type Objet_Géométrique is
    		abstract new Objet with private;
    	use Grandeurs;
    	function Aire (L_objet : Objet_Géométrique) 
    		return Surface;
    	function Périmètre (L_objet : Objet_Géométrique)
    		return Longueur;
    	-- etc...
    private
    	type Objet_Géométrique is new Objet with null record;
    end Géométrique;
    

    À partir de là, nous pouvons utiliser ce générique pour créer un nouveau type enrichi de propriétés géométriques :

    with Géométrique;
    with Classe_Objet_Graphique;
    package Classe_Graphique_Géométrique is
    	new Géométrique(Classe_Objet_Graphique.Objet_Graphique);
    

    Nous pouvons dériver de nouveaux objets graphiques indifféremment de Objet_Graphique_Géométrique si nous voulons à la fois les deux facettes, ou seulement de Objet_Graphique pour ceux (les segments par exemple) dont les propriétés géométriques n'ont aucun sens. Si nous considérons maintenant un cercle comme un objet géométrique aussi bien que graphique, il faut définir les méthodes pour le dessiner, l'effacer, ainsi que pour en calculer l'aire et le périmètre; nous devons donc (re)définir les procédures correspondantes :

    with Grandeurs;
    with Classe_Graphique_Géométrique;
    package Classe_Cercle is
    	type Cercle is new Objet_Graphique with private;
    	procedure Dessiner (Objet : Cercle);
    	procedure Effacer  (Objet : Cercle);
    private
    	type Cercle is 
    		new Classe_Graphique_Géométrique.Objet_Géométrique
    		with record
    				Rayon : Distance;
    			end record;	
    	use Grandeurs;
    	function Aire      (L_objet : Cercle)	return Surface;
    	function Périmètre (L_objet : Cercle) return Longueur;
    end Classe_Cercle;
    

    Dans cet exemple, nous annonçons, en partie visible, uniquement que le Cercle est un Objet_Graphique et nous n'exposons donc que la redéfinition des méthodes correspondantes. Dans la partie privée, nous le dérivons en fait de Classe_Graphique_Géométrique, et nous redéfinissons en partie privée les méthodes des objets géométriques. C'est autorisé, car Classe_Graphique_Géométrique est lui même dérivé de Objet_Graphique. Ainsi, nous n'exportons aux utilisateurs extérieurs que le Cercle en tant qu'Objet_Graphique, mais l'implémentation bénéficie en plus de la facette d'Objet_Géométrique. Bien entendu, on aurait pu laisser cette facette visible, mais cet exemple montre l'extrême précision que l'on peut obtenir dans le contrôle des propriétés exportées et de celles conservées privées.

Utilisation de l'héritage

modifier

L'héritage, comme nous venons de le voir, s'appuie sur des mécanismes particuliers au niveau du langage. Nous allons maintenant présenter quelques utilisations typiques de ces mécanismes. Nous verrons ensuite un exemple d'utilisation plus «méthodologique» au service de la classification.

  1. Gestionnaires de données hétérogènes
  2. Il est parfois nécessaire de définir des listes de données (ou tout autre style de gestionnaire de données) qui ne sont pas toutes du même type. Typiquement, dans une interface graphique, on maintiendra une liste de tous les widgets[1] actifs à l'écran à un moment donné. Bien sûr, ceci n'a de sens que si tous les éléments hétérogènes ont certaines propriétés communes, sinon on ne pourrait rien en faire; il est donc logique de considérer qu'ils doivent tous appartenir à une même classe. La construction de telles structures est facile en Ada grâce aux pointeurs sur classe. Supposons par exemple que nous ayons défini ainsi la classe des widgets :
    package Classe_Widget is
    	type Coordonnée is range 1..1024;
    	type Widget is abstract tagged record
    		Xmin, Xmax: Coordonnée;
    		Ymin, Ymax: Coordonnée;
    	end record;
    	procedure Evénement_Souris (Dans       : Widget;
    	                            En_X, En_Y : Coordonnée) 
    		is abstract;
    	procedure Afficher (Le_Widget : Widget);
    end Classe_Widget;
    

    Le widget occupe à l'écran un espace déterminé par ses coordonnées. La procédure Afficher demande au widget de se redessiner. En cas de «clic» de la souris sur un widget actif, on appelle la procédure Evénement_Souris en donnant les coordonnées relatives (c'est-à-dire par rapport à Xmin, Ymin). À partir de là, le widget peut déterminer ce qu'il doit faire de façon indépendante de sa position absolue à l'écran, la liaison dynamique permettant à chaque widget d'avoir sa méthode pour répondre à un clic. Cette classe abstraite sera étendue pour obtenir des widgets concrets, comme par exemple :

    with Classe_Widget; use Classe_Widget;
    package Classe_Menu is
    	type Menu is new Widget with private;
    	procedure Evénement_Souris (Dans       : Menu;
    	                            En_X, En_Y : Coordonnée);
    	type Numéro_Choix is range 1..25;
    	procedure Ajouter_Choix (A : Menu; Choix : String);
    	function  Elément_Choisi (Dans : Menu)
    		return Numéro_Choix;
    private
    	...
    end Classe_Menu;
    

    Bien sûr, il faut avoir la liste de tous les widgets à l'écran, pour déterminer où le clic a eu lieu et quel est le widget concerné. Une telle gestion de liste peut s'exprimer comme :

    with Classe_Widget; use Classe_Widget;
    package Liste_Widgets is
    	type Poignée_Widget is access all Widget'Class;
    	procedure Enregistrer    (Le_Widget : Widget'Class);
    	procedure Désenregistrer (Poignée   : Poignée_Widget);
    	-- Fonction d'itérateur
    	function Premier_Widget return Poignée_Widget;
    	function Widget_Suivant return Poignée_Widget;
    	function Fin_De_Liste   return Boolean;
    	Plus_De_Widget : exception;
    end Liste_Widgets;
    

    Cette liste nous permet d'enregistrer tous les objets dérivés de Widget, comme des menus, boutons, etc. Noter que nous n'avons pas besoin de savoir le type précis (spécifique) du widget pour accéder aux éléments Xmin, Xmax, Ymin, Ymax ni pour appeler la procédure Evénement_Souris.

    Supposons maintenant que nous voulions effectuer une opération spéciale uniquement sur les widgets qui sont des menus, comme de rajouter un élément à tous les menus affichés. Ce problème est difficile, car tout ce que l'on sait a priori des éléments de la liste est qu'ils appartiennent à un type dérivé plus ou moins directement de Widget. Il faut donc «remonter» le typage de «un widget» vers la notion plus précise de «un menu». Nous pouvons tester si une valeur appartient à un type par l'opérateur in et convertir un type à l'échelle de classe vers n'importe lequel de ses descendants. Par exemple :

    declare
    	Poignée : Poignée_Widget := Premier_Widget;
    begin
    	while not Fin_De_Liste loop
    		if Poignée.all in Menu then
    			Ajouter_Choix(A     => Menu (Poignée.all),
    			              Choix => "Coucou");
    		end if;
    	end loop;
    end;
    

    Si nous n'avions pas pris la précaution de le tester avant, il se pourrait que nous tentions de convertir en Menu quelque chose qui serait un autre dérivé de Widget – un bouton par exemple; dans ce cas, il y aurait automatiquement levée de l'exception Constraint_Error. La cohérence des types reste donc garantie, même en présence de structures hétérogènes. Cependant, cette cohérence ne peut être assurée qu'à l'exécution, alors que pour les types non étiquetés elle est toujours vérifiée à la compilation : on voit ici encore qu'une plus grande dynamicité s'obtient au prix d'un affaiblissement du typage.

    1. Abréviation homologuée pour window gadget, toute «bricole» apparaissant à l'écran, telle que fenêtre, bouton, menu, etc.
  3. Implémentations multiples
  4. La classification et le polymorphisme permettent d'utiliser des objets dont il existe plusieurs implémentations, de façon transparente. Imaginons par exemple un logiciel qui dispose d'une fonction d'impression. Dans des environnements tels que Windows, l'utilisateur peut choisir à tout moment de changer de type d'imprimante. Comment le logiciel va-t-il faire pour savoir quels sont les bons codes à envoyer? Il suffit de dire qu'il existe une classe des objets «imprimante», munie d'un certain nombre d'opérations : écrire une ligne, changer de jeu de caractères, etc. Au départ, on définit ceci comme :
    package Classe_Imprimante is
    	type Imprimante is abstract tagged null record;
    	type Enrichissement is (Normal, Gras, Souligné);
    	procedure Sélectionner (Effet: Enrichissement);
    	type Alignement is (Gauche, Droit, Justifié, Centré);
    	procedure Sélectionner (Justification : Alignement);
    	-- etc...
    	procedure Imprimer (Texte : String);
    end Classe_Imprimante;
    

    Ensuite, on pourra dériver des classes, selon les différents modèles d'imprimantes. Une procédure d'impression peut alors ignorer totalement sur quel périphérique physique elle s'effectue :

    procedure Imprimer_Fichier (Nom : String; 
                                Sur : Imprimante'Class) is
    begin
    	...
    	Sélectionner (Effet => Gras);
    	Sélectionner (Justification => Justifié);
    	Imprimer (Ligne);
    	...
    end Imprimer_Fichier;
    

    Du point de vue abstrait, on passe à la procédure un objet «abstraction d'imprimante», et la procédure lui demande d'effectuer les différentes opérations. Du point de vue du langage, la liaison dynamique assure la ventilation à l'exécution de la demande vers le sous-programme associé, qui n'a même pas besoin d'être connu au moment de la compilation de la procédure utilisatrice.

  5. Objets répartis
  6. La conjonction du modèle d'exécution répartie (cf. paragraphe Systèmes répartis) et de la liaison dynamique permet de créer des objets répartis, c'est-à-dire situés sur des nœuds d'un réseau et appelables depuis d'autres nœuds. La liaison dynamique peut s'effectuer de façon transparente à travers le réseau sans que l'utilisateur ait à s'en préoccuper. Imaginons par exemple des serveurs de bandes magnétiques. Différents nœuds disposent de lecteurs, éventuellement de modèles différents. Nous représentons ainsi la classe des bandes magnétiques :
    package Bandes is
    	pragma Pure(Bandes);
    	type Donnees is ...;
    	type Instance is abstract tagged limited private;
    	procedure Rembobiner (T : access Instance) is abstract;
    	procedure Lire       (T      : access Instance; 
    	                      Valeur : out    Donnees) is abstract;
    	procedure Ecrire	     (T      : access Instance; 
                            Valeur : in      Donnees) is abstract;
    private
    	type Instance is abstract tagged limited null record;
    end Bandes;
    

    Nous définissons ensuite un «serveur de nom», c'est-à-dire un service permettant d'enregistrer sous un nom logique des pointeurs sur des objets Bandes. Le paquetage ayant le pragma Remote_Call_Interface, le type pointeur qui y est défini est un type «étendu», c'est-à-dire qu'il contient l'information nécessaire pour retrouver l'objet sur le réseau :

    with Bandes;
    package Serveur_De_Nom is
    	pragma Remote_Call_Interface;
    	type Ptr_Bande is access all Bandes.Instance'Class;
    	function  Trouver     (Nom  : String) return Ptr_Bande;
    	procedure Enregistrer (Nom  : String; Bande : Ptr_Bande);
    	procedure Retirer     (Bande: Ptr_Bande);
    end Serveur_De_Nom;
    

    Nous définissons ensuite les caractéristiques d'un certain modèle de dérouleur. Ce paquetage ne se trouvera physiquement que sur le seul nœud possédant le dérouleur considéré :

    package Gestionnaire_Bande is
    	pragma Elaborate_Body (Gestionnaire_Bande);
    end Gestionnaire_Bande;
    with Bandes, Serveur_De_Nom;
    package body Gestionnaire_Bande is
    	type Autre_Bande is new Bandes.Instance with ...
    	procedure Rembobiner (T : access Autre_Bande) is ...
    		-- Redéfinition de Rembobiner
    	procedure Lire (T : access Autre_Bande; Valeur : out Donnees) is ...
    		-- Redéfinition de Lire
    	procedure Ecrire (T : access Autre_Bande; Valeur : in Donnees) is ...
    		-- Redéfinition de Ecrire
    	Bande1, Bande2 : aliased Autre_Bande;
    begin
    	Serveur_De_Nom.Enregistrer ("Bande 1", Bande1'Access);
    	Serveur_De_Nom.Enregistrer ("Bande 2", Bande2'Access);
    end Gestionnaire_Bande;
    

    Remarquer que le corps du paquetage enregistre les objets Bande1 et Bande2 auprès du serveur de nom. Le client (qui n'a aucune raison de se trouver sur le même nœud que le dérouleur) récupérera via le serveur de nom un pointeur (distant) sur l'objet désiré. Tous les appels aux méthodes de cet objet seront donc routés à travers le réseau vers l'objet considéré :

    with Bandes, Serveur_De_Nom;
    procedure Client is
    	B1, B2 : Serveur_De_Nom.Bande_Ptr;
    	Tampon : Donnees;
    	use Bandes;
    begin
    	B1 := Serveur_De_Nom.Trouver ("Bande 1");
    	B2 := Serveur_De_Nom.Trouver ("Bande 2");
    	Rembobiner (B1); 
    	Rembobiner (B2);
    	Lire (B1, Tampon);
    	Ecrire (B2, Tampon);
    end Client;
    with Tapes, Name_Server; use Tapes;
    procedure Client is
    	T1, T2 : Name_Server.Tape_Ptr;
    	Buffer : Data;
    begin
    	T1 := Name_Server.Find ("Bande A");
    	T2 := Name_Server.Find ("Bande B");
    	Rewind(T1);
    	Rewind(T2);
    	Read (T1, Buffer);
    	Write(T2, Buffer);
    end Client;
    
    À faire... 

    Remettre à jour les notes sur CORBA

    Ce mécanisme est très efficace, car seule l'obtention d'un pointeur sur l'objet requiert un appel au serveur de nom; l'appel de ses méthodes s'effectue directement à travers le réseau. Ce modèle est parfaitement compatible avec le modèle CORBA[1]; le mécanisme de communication entre les partitions peut utiliser le format IIOP, norme d’interopérabilité de CORBA. D’autre part, les schémas d'implémentation de CORBA avec Ada 95 ont été standardisés, et plusieurs fournisseurs offrent des outils de traduction d'IDL[2] vers Ada, aussi bien en «libre» qu’en propriétaire traditionnel.

    1. Common Object Request Broker Architecture : modèle de définition de serveur d'objets distribués, en cours de standardisation.
    2. Interface Definition Language : langage de spécification d'objets distribués, indépendant des langages de programmation, également en cours de standardisation.

    Exemple en classification

    modifier

    Après avoir vu les mécanismes du langage, nous allons voir comment les utiliser au service de la méthode par classification. Supposons que nous voulions développer le système de paye d'une entreprise. Il existe de nombreuses sortes d'employés : certains disposent d'un salaire fixe, d'autres sont payés à la commission; les charges sociales ne se calculent pas de la même façon pour un apprenti et un employé normal... Nous allons donc identifier ce qui est commun à tous les employés, puis modéliser chacune des modalités de paye dans des classes représentant directement chacun des types d'employés.

    Première question : qu'est-ce qu'un employé, en général, indépendamment de toute spécialisation ultérieure? C'est d'abord la représentation d'une personne, caractérisée par un nom, un numéro d'identification, un âge... En fait ce genre de caractéristiques n'est pas fondamental pour le problème courant; nous pouvons nous contenter de repérer chaque employé par un numéro. Nous pourrons rajouter par la suite les autres caractéristiques, les spécialisations que nous aurons faites de l'employé en bénéficieront automatiquement grâce au mécanisme d'héritage.

    Les opérations que doit fournir la classe des employés, pour notre vue, sont le calcul du salaire et celui des charges sociales. Ceux-ci sont toujours calculés par rapport à une certaine «base» : salaire de référence, catégorie, nombre de points... Ceci dépend de l'entreprise. Pour notre exemple, nous considérerons que cette base est donnée sous forme d'un montant en argent. Ensuite, chaque forme de salarié peut nécessiter l'adjonction d'une mention spéciale sur le bulletin de paye. Nous prévoyons donc une fonctionnalité à cet effet. Enfin, il n'est pas possible d'avoir un employé qui n'appartienne pas à quelque sous-catégorie plus raffinée; l'employé doit donc être un type abstrait. Nous allons exprimer cette analyse dans le langage; comme nous avons choisi de travailler ici en classification, nous adopterons la convention de nommer le paquetage avec le nom de l'entité, et d'appeler systématiquement le type associé Instance. Ainsi, un nom de la forme Employé.Instance désignera clairement une instance d'employé. Par analogie, nous définirons systématiquement un nom pour le type à l'échelle de classe associé, que nous appellerons Classe. On trouvera la justification complète de cette convention dans [Ros95]. Ceci nous donne (nous supposons que le type Argent est défini dans le paquetage Données_Globales) :

    with Données_Globales; use Données_Globales;
    package Employé is
    	type Numéro_Employé is range 1..10_000;
    	type Instance is abstract tagged
    		record
    			Numéro    : Numéro_Employé;
    			Référence : Argent;
    		end record;
    	subtype Classe is Instance'Class;
    	function Salaire (De : Employé.Instance) return Argent
    		is abstract;
    	function Charges (De : Employé.Instance) return Argent
    		is abstract;
    	procedure Mentions_Spéciales (De : Employé.Instance);
    end Employé;
    

    Remarquer que la procédure Mentions_Spéciales n'est pas abstraite : nous fournirons simplement une implémentation qui ne fait rien. Ainsi, tous les types dérivés ultérieurs qui n'ont pas à imprimer de mentions spéciales pourront conserver la procédure par défaut dont ils auront hérité.

    À partir de là, il existe deux sortes d'employés : les salariés (dont le salaire est fixe) et les commissionnés, dont le salaire dépend d'une commission, ou d'une autre forme de rétribution liée à leurs performances. Les salariés n'ont besoin a priori d'aucune fonctionnalité supplémentaire : nous allons les dériver directement des employés :

    with Données_Globales; use Données_Globales;
    with Employé;
    package Salarié is
    	type Instance is new Employé.Instance with null record;
    	subtype Classe is Instance'Class;
    	function Salaire (De : Salarié.Instance) return Argent;
    	function Charges (De : Salarié.Instance) return Argent;
    	procedure Mentions_Spéciales (De : Salarié.Instance);
    end Salarié;
    

    Nous obtenons une classe non abstraite en la dérivant de la classe abstraite et en fournissant la définition des sous-programmes hérités qui étaient déclarés abstraits. Avec notre convention, les utilisateurs pourront déclarer des objets ou des procédures comme :

    J_P_Rosen : Salarié.Instance;
    procedure Emettre_Bulletin_Paye (Pour : Employé.Classe);
    

    Remarquer que nous utilisons toujours les types sous la forme de noms complets, comme Salarié.Instance ou Employé.Classe, exprimant clairement la différence entre les entités portant sur une instance particulière et celles applicables à toute une classe. Ceci correspond encore à une sorte de documentation, vérifiée par le langage, de ce que nous voulons exprimer.

    Puisque nous ne rajoutons rien pour faire les Salarié, ne pourrait-on supprimer Employé et mettre Salarié comme racine de l'arbre de dérivation? Du point de vue du langage, c'est tout à fait faisable. Mais d'une part, notre analyse a identifié que les salariés et les commissionnés étaient deux formes d'employés : il faut traduire cette structure dans le langage. Ensuite, rien ne dit que, par la suite, nous ne serons pas amenés à rajouter aux salariés des éléments qui n'ont aucun sens pour les commissionnés; de même peut-être voudrons-nous écrire des procédures applicables à tous les salariés, mais non aux commissionnés (en termes de langage, une procédure avec un paramètre de type Salarié.Classe). Si nous faisions de Salarié la classe racine, ces éléments seraient également transmis aux Commissionné.

    Les commissionnés sont payés en fonction d'un (petit) fixe, correspondant au salaire de référence, et de leur chiffre d'affaire. En plus des paramètres communs à tout employé, un commissionné doit posséder un chiffre d'affaire courant. Ce qui nous donne :

    with Données_Globales; use Données_Globales;
    with Employé;
    package Commissionné is
    	type Instance is new Employé.Instance with 
    		record
    			Chiffre_d_Affaire : Argent := 0.0;
    		end record;
    	subtype Classe is Instance'Class;
    	function Salaire (De : Commissionné.Instance)
    		return Argent;
    	function Charges (De : Commissionné.Instance)
    		return Argent;
    	procedure Mentions_Spéciales(De: Commissionné.Instance);
    end Commissionné;
    

    Tout employé peut cotiser volontairement à une caisse de retraite; cette cotisation est prise sur son salaire, mais est déductible des charges sociales[1]. Il accumule des cotisations, mais peut choisir à tout moment le montant à cotiser et interroger l'état de son compte. Il faut donc pouvoir rajouter une facette «Cotisant» à tout employé (mais pas à un type qui n'appartiendrait pas à la classe des employés). Nous traduirons ceci comme :

    with Données_Globales; use Données_Globales;
    with Employé;
    generic
    	type Origine is new Employé.Instance with private;
    package Cotisant is
    	type    Instance is new Origine with private;
    	subtype Classe   is Instance'Class;
    	function Salaire (De : Cotisant.Instance) return Argent;
    	function Charges (De : Cotisant.Instance) return Argent;
    	procedure Mentions_Spéciales (De : Cotisant.Instance);
    	procedure Changer_Cotisation
    		(De         : in out Cotisant.Instance; 
    		 Cotisation : in     Argent);
    	function Montant_Cotisé (Par : Cotisant.Instance)
    		return Argent;
    private
    	type Instance is new Origine with 
    		record
    			Cotisation_mensuelle : Argent := 0.0;
    			Cotisation_Accumulée : Argent := 0.0;
    		end record;
    end Cotisant;
    

    À partir de là, il est facile de définir la classe des commissionnés cotisants :

    with Commissionné, Cotisant;
    package Commissionné_Cotisant is 
    	new Cotisant (Commissionné.Instance);
    

    Grâce à notre convention de nommage, nous pouvons parler de Commissionné_Cotisant.Instance ou de Commissionné_Cotisant.Classe de la même façon que pour les types qui n'ont pas été obtenus par des génériques. Bien entendu, il faut déduire le montant de la cotisation du montant du salaire; ceci se fait facilement en redéfinissant le calcul du salaire d'un cotisant comme ceci :

    function Salaire (De: Cotisant.Instance) return Argent is
    begin
    	return Salaire (Origine (De)) - Montant_Cotisé (De);
    end Salaire;
    

    Ceci exprime bien que le salaire d'un Cotisant est son salaire d'origine diminué du montant cotisé. De même, si notre Cotisant veut rajouter une mention spéciale, celle-ci ne doit pas remplacer, mais s'ajouter aux mentions spéciales que le type d'origine pourrait avoir. Il faut donc appeler la procédure d'origine avant la nôtre propre :

    procedure Mentions_Spéciales (De : Cotisant.Instance) is
    begin
    	Mentions_Spéciales( Origine (De));
    	Put ("Cotisation : ");
    	Put (Montant_Cotisé (Cotisant.Classe (De)));
    end Mentions_Spéciales;
    

    Remarquer que nous appelons le montant cotisé en convertissant le paramètre vers la classe pour forcer une liaison dynamique : si un type dérivé redéfinit les modalités de calcul des cotisations, on appellera cette fonction, et non celle définie pour Cotisant.Instance. Il ne sera pas nécessaire de redéfinir la procédure Mentions_Spéciales : la procédure ci-dessus a déjà été prévue pour fonctionner avec les types qui en hériteront plus tard.

    Nous pourrions continuer longtemps ainsi; chaque fois qu'une nouvelle forme d'employé apparaît, il s'agit généralement (légalement) d'une subdivision d'une classification d'employé existante : il nous suffit de refléter dans notre programme la hiérarchie exacte définie par les textes légaux, conventions collectives, etc. Lorsqu'une variation s'applique de façon orthogonale à la hiérarchie, nous la rajoutons sous la forme d'une facette, comme pour le cas de Cotisant ci-dessus.

    Une fois définie notre hiérarchie de types, il nous reste à l'utiliser. Ceci se fait en général dans un contexte plus procédural. Si l'on veut éditer un bulletin de paye, il serait ridicule de définir une classe «bulletin»... qui n'aurait aucune sous-classe[2]. Une telle procédure pourrait avoir le schéma général suivant :

    procedure Editer_Bulletin (De: Employé.Classe) is
    begin
    	...
    	Put ("Salaire : "); Put (Salaire (De)); New_Line;
    	Put ("Charges : "); Put (Charges (De)); New_Line;
    	Mentions_Spéciales (De);
    	...
    end Editer_Bulletin;
    

    Cette procédure peut traiter n'importe quel employé, et le mécanisme de la liaison dynamique garantit que le calcul du salaire, des charges, ainsi que les mentions spéciales imprimées seront bien ceux correspondant à l'instance utilisée pour l'appel.

    1. Si un législateur social lit ces lignes, qu'il veuille bien pardonner les simplifications et inexactitudes de ce modèle....
    2. Ceci ne signifie pas que tous les bulletins sont semblables, mais que les différences proviennent des employés, non des bulletins eux-mêmes.

    La classification en Ada et dans d'autres langages

    modifier

    L’héritage et les autres services nécessaires à la classification n’étant apparus en Ada qu’en 1995, celui-ci a naturellement tiré les leçons de ses prédécesseurs et adopté des mécanismes cohérents avec le reste de sa philosophie, mais qui diffèrent parfois de ceux d'autres langages orientés objet. Nous allons maintenant comparer la façon de réaliser les concepts de la classification en Ada avec celle d'autres langages, et montrer les raisons des solutions adoptées. Nous utiliserons pour cette comparaison principalement Java et C++, parce que ce sont les langages orientés objet les plus populaires actuellement. Nous ne parlerons pas de SmallTalk, car son absence totale de typage (qui est un atout pour le maquettage) l'éloigne des domaines où l'on utilise Ada. Quant à C#, il n’est pas suffisamment différent de Java sur le plan du principe pour qu’il soit utile d’en parler spécifiquement.

    1. Classes et modularisation
    2. La plupart des LOO disposent d'une construction syntaxique spéciale pour la notion de classe, qui regroupe dans une même déclaration les attributs et les méthodes de la classe. Ada ne possède pas de telle construction : une classe est réalisée par un paquetage contenant la déclaration d'un type étiqueté et des opérations associées. Une classe est donc une sorte de patron de conception (design pattern), ce qui la rend donc moins visibles en tant que telles que dans les autres LOO. Ceci provient du choix d'Ada de fournir des «blocs de base» (building blocks) permettant de réaliser les structures nécessaires à toutes les méthodologies. Inversement, en Java, la classe est la seule unité de structuration, ce qui oblige à tout définir sous forme de classes. La classe est également l'unité de compilation séparée, ce quit interdit de regrouper des classes logiquement reliées et conduit à une fragmentation des programmes en unités trop petites. Chaque classe appartient logiquement à un paquetage qui en limite la visibilité : ainsi, seules les classes dites «publiques» sont visibles d’un paquetage à un autre. Toutefois, cette notion de paquetage ne correspond à aucune structuration physique en unités de compilation; il n’est pas possible, par exemple, de déterminer simplement l’ensemble des classes appartenant à un paquetage. Ce n’est donc pas une aide à la modularisation. C++ occupe une position intermédiaire; le fichier source a un effet de visiblité locale, et il est possible de déclarer dans un même fichier plusieurs classes, et même des fonctions non nécessairement reliées à une classe. Ceci permet un certain regroupement des classes logiquement reliées, mais sans faire apparaître clairement la notion de module au niveau du langage. La classe reste le principal moyen de définir des types évolués; on voit alors apparaître des «fausses classes» qui ne servent qu'à encapsuler des éléments, sans aucune relation avec la classification en tant que méthode[1]. Quant aux namespace, ils permettent de contrôler la visibilité des identificateurs, mais n’offrent pas plus de structuration logique que les paquetages Java. La solution des blocs de base adoptée par Ada présente un certain nombre d'avantages, au moins dans le cadre d'un langage généraliste :
  • Les notions d'encapsulation et de typage sont orthogonales. De plus, le mécanisme utilisé pour le support de l’héritage (les types étiquetés) la classification est indépendant du mécanisme utilisé pour la modularisation (les paquetages).
  • Certains utilisateurs peuvent ne vouloir que certaines fonctionnalités; par exemple, on peut utiliser l'extension de types sans pour autant avoir besoin de la liaison dynamique. L'utilisateur ne paie alors le prix que des fonctionnalités qu'il utilise.
  • Il n'existe aucun problème pour imbriquer les déclarations. Un paquetage définissant une classe peut définir un autre paquetage de classe imbriqué; une procédure (qu'elle soit ou non une «méthode» d'un type étiqueté) peut contenir ses propres classes locales, etc. Sans entrer dans les détails techniques, noter qu'aucun autre langage orienté objet n'offre cette possibilité, car elle complique notablement l'écriture du compilateur.
  1. On entend ainsi parfois parler de «classes fonctionnelles» : le nom même apparaît comme un désaveu de la méthode.
  • Instances et sémantique de référence
  • Certains langages orientés objet, dans un but d'unification des concepts, ne font pas de différence entre classes et instances; une classe est alors une instance d'une métaclasse, ou classe de classes. Cette approche est mathématiquement et intellectuellement très pure, mais ne conduit pas nécessairement à une meilleure lisibilité ni à une plus grande maintenabilité des programmes... Dans la plupart des langages à objets, une variable déclarée avec un type peut contenir non seulement des valeurs de son type, mais également tout objet correspondant à sa classe. Autrement dit, on ne fait pas la différence entre ce que l'on appelle en Ada un type spécifique et un type à l'échelle de classe. En Java par exemple, une variable déclarée comme Objet_Graphique pourrait contenir n'importe quel objet graphique, aussi bien un Cercle qu'un Rectangle. Ce n'est possible que parce qu'au niveau de l'implémentation, les variables ne sont que des pointeurs vers les instances. Un objet ne peut exister simplement parce qu'il est déclaré : il faut une opération de création explicite. Malheureusement, ceci implique que toutes les classes sont à sémantique de référence : il n'est pas possible de créer de types abstraits à sémantique de valeur. Une telle solution, si elle peut être commode pour des conceptions fondées uniquement sur le mécanisme d'héritage et le polymorphisme, aurait été inacceptable en Ada, qui doit pouvoir soutenir toutes les méthodologies. Il est bien entendu possible d'obtenir en Ada le même comportement qu'avec les autres langages orientés objet, en travaillant uniquement avec des pointeurs sur classe. Mais cela n'est pas nécessaire pour bénéficier ni de la liaison dynamique, ni des autres mécanismes de la POO. Il n'y a donc pas de pointeurs cachés qui interdiraient quasiment l'utilisation de ces mécanismes dans des contextes «temps réel».
  • Variables de classe
  • Certains LOO autorisent la définition de variables de classe, par opposition aux attributs normaux appelés variables d'instance. Une variable de classe est, comme son nom l'indique, liée à une classe, et donc partagée par toutes les instances appartenant à la classe. Ceci correspond à la notion de membre statique en Java et en C++. Imaginons par exemple que nous voulions assigner un numéro différent à chaque nouvel objet créé : il faut bien avoir une variable globale servant de compteur d'objet, qui doit être accessible depuis toutes les instances. Et comme nous ne souhaitons pas autoriser un accès indiscipliné à cette variable à l'extérieur de la classe, il faut une variable privée, non pas à chaque instance, mais à la classe. Ainsi formulée, cette notion paraît quelque peu bizarre. Sa réalisation en Ada est beaucoup plus naturelle : il s'agit simplement d'une variable globale du corps de paquetage :
    package Classe_Objets is
    	type Objet is tagged
    		record
    			...
    		end record;	 Opérations sur Objet
    end Classe_objet;
    package body Classe_Objets is
    	type T_Compteur is range 0..1000;
    	Compteur : T_Compteur := 0;
    	 Implémentation des opérations
    end Classe_Objets;
    
  • Association des méthodes aux objets
  • Dans la plupart des langages orientés objet, une méthode est «propriété» d'un objet, et l'appel d'une méthode utilise une syntaxe particulière. Si un objet O appartient à la classe C et qu'on veut appeler sa méthode M, on écrira :
    O.M;
    

    En Ada, une méthode est simplement une procédure qui possède un paramètre du type C, et l'appel s'écrit :

    M (O);
    

    Apparemment, la solution des LOO correspond mieux à la notion de «demander à l'objet O d'exécuter sa méthode M». Cependant, le problème se complique si la méthode doit porter sur plusieurs paramètres de la même classe; l'écriture «LOO» rompt la symétrie :

    O1.M(O2);
    

    alors que celle-ci est conservée en Ada :

    M(O1, O2);
    

    Si nous définissons une sous-classe S de C (en termes Ada, nous dérivons le type S du type étiqueté C), il n'y a avec les LOO classiques qu'un seul terme principal qui détermine la méthode appelée. Par conséquent, la méthode dont héritera un objet de classe dérivée D sera une méthode dont le paramètre sera de classe C; en Ada, tous les paramètres sont dérivés, ce qui signifie que la procédure M dérivée portera bien sur deux paramètres de type D. Supposons par exemple que nous redéfinissions la comparaison d'égalité :

    type C is tagged record ...;
    function "=" (O1, O2 : C) return Boolean;
    type D is new C with ...;
    -- on hérite de :
    -- function "=" (O1, O2 : D) return Boolean;
    

    Dans le cas des autres LOO, on hériterait d'une fonction de comparaison d'un C avec un D, c'est-à-dire de quelque chose qui n'aurait aucun sens! La solution Ada est donc moins «pure» du point de vue de la théorie des LOO, mais elle est plus sûre et fournit un comportement plus conforme aux attentes de l'utilisateur dès qu'une méthode doit porter sur plus d'un seul objet.

    Notons que la syntaxe Ada permet de disposer automatiquement d'un nom (celui du paramètre) pour désigner l'objet sur lequel porte la fonction, éliminant le besoin de constructions spéciales (current en Eiffel ou this en Java et C++).

    Ce point syntaxique pourrait paraître mineur, mais les utilisateurs d’autres langages sont tellement habitués à la notation préfixée que la notation Ada représente un obstacle sérieux à la compréhension du modèle Ada de programmation orientée objet. Et dans les cas simples, la notation préfixée est effectivement plus naturelle. Pour ces raisons, Ada 2005 inclue une notation alternative, où O.M(P) est équivalent à M(O,P). Il s’agit d’une simple équivalence syntaxique sans changement sur le principe.

    Enfin, la notation Ada autorise la liaison dynamique sur le type retourné par une fonction. Étant données les définitions suivantes :

    type T is tagged record ...;	
    function F return T;
    type D is new T with ...;
    function F return D;
    

    il est possible de faire un appel dynamique à la fonction F, où le contexte d’appel déterminera, dynamiquement, quelle fonction est appelée. Cette possibilité n’existe ni en Eiffel, ni en C++, ni en Java...

    À faire... 

    Parler aussi du dispatching sur valeur de retour de fonction

  • Liaison dynamique
  • La liaison dynamique est une propriété fondamentale des langages orientés objet. En Java, elle est systématique : c'est toujours l'instance qui détermine la méthode effectivement appelée. Il est possible cependant de renommer un objet dans un type ancêtre (parent direct ou type ancêtre plus éloigné) pour forcer l’appel d’une méthode ancêtre redéfinie. méthode du parent redéfinie par un enfant, ce qui permet de l'utiliser depuis l'enfant. Si l'on veut appeler des ancêtres plus lointains, il faut avoir prévu des renommages dans les parents intermédiaires. En C++, les méthodes «normales» n'effectuent jamais de liaison dynamique;seules celles définies avec le mot clé virtual peuvent le faire. Il est possible de forcer un appel statique vers un ancêtre, mais en l'absence d'une telle spécification explicite on ne peut pas savoir, sans regarder la définition de la méthode, si l'appel est statique ou dynamique. Le mécanisme adopté par Ada a l'avantage de toujours laisser le choix à l'appelant d'effectuer un appel spécifique ou dynamique. Ceci oblige le programmeur à réfléchir précisément aux propriétés souhaitées, mais permet de connaître précisément, à la lecture du programme, les conditions d'appel.
  • Types abstraits
  • Cette notion est présente de façon extrêmement voisine en Java. Comme en Ada, seules les classes abstraites peuvent posséder des méthodes abstraites. En C++, il est possible de définir des méthodes abstraites (appelées virtuelles pures) en affectant la valeur 0 à la fonction (!!). La classe correspondante devient abstraite sans que cela apparaisse syntaxiquement dans la définition de la classe. En particulier, il n’est pas possible de définir une classe abstraite ne possédant aucune méthode abstraite.
  • Approches de l'héritage multiple
  • Nous avons vu qu'en Ada, une approche par composition de générique permet de voir un même objet selon plusieurs facettes différentes. D'autres langages utilisent pour cela la notion d'héritage multiple, qui correspondrait en Ada au fait de pouvoir dériver de plusieurs types à la fois. Si l'héritage multiple est séduisant en théorie, il apporte avec lui un problème immédiat : lorsque l'on cherche où se trouve une méthode appliquée à une instance, il faut remonter plusieurs chemins (puisque plusieurs classes ancêtres sont autorisées). Que faire si plusieurs méthodes portant le même nom sont accessibles par des chemins différents ?
     
    Figure 18 : Exemple de graphe d'héritage multiple
    Figure 18 : Exemple de graphe d'héritage multiple

    Considérons le graphe de la figure 18. L'objet X hérite d'une méthode M définie dans C aussi bien que dans D. Mais celle de C est héritée de B, où il s'agit d'une redéfinition de celle de A, alors que celle de D est une redéfinition directe de celle de A. Laquelle prendre ? Celle de A qui est commune à tout le monde? Celle de D qui est plus directe ? Celle de C (donc de B) parce que c'est la plus à gauche[1]?

    Ce problème est connu dans la littérature sous le nom d'héritage répété. Il est très important dans le domaine de l'intelligence artificielle lorsqu'il s'agit de modéliser des connaissances, car il faut alors trouver les relations conceptuelles exactes qui lient les différents objets. Dans le domaine qui nous intéresse, celui du génie logiciel, il s'agira de coïncidences de noms généralement fortuites, et ce problème sera résolu par des méthodes autoritaires. En Eiffel par exemple, l'utilisateur doit manuellement lever toute ambiguïté (en fournissant d'autres noms, non ambigus, aux méthodes héritées homonymes).

    La solution choisie par Ada au problème des vues multiples permet de s'affranchir de ce problème : l'enrichissement des classes se faisant par ajouts successifs, les nouvelles méthodes remplacent naturellement les anciennes. C'est l'ordre d'instanciation (choisi par l'utilisateur) qui détermine la méthode effectivement utilisée. De plus, cette solution procure une grande finesse dans la définition des relations entre objets. Si un générique de facette annonce :

    generic
    	type Origine is abstract tagged limited private;
    package Facette is ...
    

    alors il pourra être utilisé avec n'importe quel type étiqueté. Mais s'il annonce :

    generic
    	type Origine is new Objet_Géométrique with private;
    package Facette is ...
    

    alors, il ne pourra être instancié que sur un descendant (direct ou indirect) du type Objet_Géométrique. Autrement dit, on peut créer ainsi des facettes qui ne peuvent servir qu'à enrichir certaines classes bien précises (mais qui du coup peuvent tirer parti de la connaissance supplémentaire des propriétés du type ancêtre). En héritage multiple classique, cela reviendrait à créer une classe dont on ne pourrait hériter que si l'on héritait simultanément d'une autre classe; un tel type de contrôle serait très difficile à définir.

    1. Ce critère de choix ne paraîtra stupide qu'à ceux qui n'ont jamais eu à écrire un compilateur...
  • L’héritage d’interfaces
  • La notion d’interface est un concept introduit par Java (et repris par C#), principalement dans le but de fournir les services de l’héritage multiple tout en paliant ses inconvénients. Afin de bien comprendre de quoi il s’agit, nous allons revenir quelque peu sur le mécanisme d’héritage. Dans la présentation que nous en avons faite ci-dessus, nous avons classiquement expliqué qu’une classe parent fournit des attributs et des méthodes, et que l’on peut en dériver une classe enfant, possédant les mêmes propriétés et méthodes (au moins), puisqu’elle en hérite. De plus, si le comportement de certaines méthodes héritées ne conviennent pas à la classe enfant,, celle-ci peut les redéfinir, c’est-à-dire fournir un comportement différent pour la même méthode. Au passage, on remarque que toute méthode est soit héritée, soit redéfinie, mais ne peut disparaître. Par conséquent, tous les descendants d’une classe ont (au moins) les mêmes méthodes que leurs ancêtres. Cette façon de présenter l’héritage correspond à l’approche dite programmation delta : on voit une classe enfant comme une légère modification du comportement d’une classe existante. On peut cependant présenter l’héritage d’une manière légèrement différente. Une classe parente définit un ensemble de propriétés partagées par tous ses descendants. Par exemple, tous les animaux mangent; par conséquent, la classe de tous les animaux fournit la méthode «Manger». Ensuite, chaque forme particulière d’animal définit comment elle mange en fournissant sa propre définition de «Manger». Il peut arriver cependant que toutes les sous-classes d’une classe donnée partagent une même façon de faire ; par exemple, toutes les variétés de chiens mangent de la même façon. Il est alors commode que la classe «Chien» fournisse une implémentation par défaut de «Manger», qui sera partagée par toutes ses sous-classes, à moins d’être explicitement redéfinie. Dans cette seconde présentation, nous mettons l’accent sur la présence d’une interface commune, dont la présence est garantie chez tous les descendants d’une classe; l’héritage des méthodes n’a qu’un rôle secondaire, une sorte de valeur par défaut qui peut, mais pas nécessairement, être fournie par un ancêtre. D’ailleurs, la notion de méthode abstraite vue précédemment correspond exactement à cela : une méthode pour laquelle l’ancêtre ne fournit pas de valeur par défaut, et qui doit donc être redéfinie par les descendants. De ces deux aspects de l’héritage, il apparaît clairement que le plus important est la notion d’interface garantie; c’est ce qui permet le polymorphisme, la possibilité qu’une variable contienne des objets dont le type n’est pas connu statiquement, tout en autorisant de lui appliquer les méthodes appropriées. Une interface en Java est simplement une forme d’héritage réduit à son premier aspect : la définition d’un ensemble de méthodes fournies, sans valeurs par défaut[1]. Une classe qui implémente l’interface promet de fournir les méthodes définies par l’interface. Un exemple simple d’interface est donné dans l’exemple suivant :
    public interface Enumeration { // Dans le paquetage java.util
    	public boolean hasMoreElements (); 
    	public Object nextElement () throws NoSuchElementException; 
    }
    

    Il est alors possible de définir une méthode qui s’applique à toute classe qui implémente l’interface Enumeration :

    //Cette méthode imprime tous les éléments d'une structure de données :
    void listAll (Enumeration e) {
    	while e.hasMoreElements ()
    		System.out.println (e.nextElement());
    }
    

    Si une classe Java n’a le droit d’hériter que d’une seule autre classe, elle peut déclarer implémenter autant d’interfaces qu’elle le souhaite; ceci répond à la plupart des besoins pour lesquels l’héritage multiple peut être utile, mais sans les problèmes dus à l’héritage répété, puisque la méthode doit être explicitement redéfinie.

    D’une certaine façon, on peut dire qu’une interface n’est rien d’autre qu’une classe abstraite qui ne définit aucun attribut, et dont toutes les méthodes sont abstraites. Mais on peut même considérer que la notion d’interface n’a rien à voir avec l’héritage, et constitue même en un sens le contraire de l’héritage : ce n’est qu’un contrat qui peut être respecté par différentes classes, sans qu’il soit nécessaire d’avoir aucune relation conceptuelle entre les différentes classes qui implémentent l’interface. En bref, l’héritage correspond à une relation «est un», alors que les interfaces correspondent à une relation «fournit».

    En plus de cette relation de base, une interface peut en étendre une autre; ceci introduit alors une vraie relation d’héritage entre les interfaces; mais il n’en reste pas moins que la notion d’implémentation d’interface n’est pas en elle-même une relation d’héritage.

    Il est possible d’obtenir en Ada l’équivalent du mécanisme d’héritage d’interface au moyen d’un patron de conception particulier [Ros02]. La méthode utilisée est cependant un peu complexe à mettre en œuvre, compte tenu de la popularité croissante de la notion d’interface. Il est donc prévu qu’Ada 2005 fournisse un mécanisme d’héritage d’interface, très proche dans son principe de ce qui est fourni par Java.

    À faire... 

    Parler des interfaces. Mettre le papier AE02?

    1. Une interface ne peut pas non plus définir d’attributs.

    Conclusion

    modifier

    Les types étiquetés d’Ada fournissent désormais, l'outil de base qui lui manquait pour les méthodes par classification. Les objets à facettes multiples sont réalisés par un moyen original, l'utilisation de la généricité, plutôt que par un mécanisme spécial du langage comme l'héritage multiple. Cette différence d'approche par rapport aux autres langages permet une meilleure sécurité et un contrôle plus précis du typage et des relations entre classes, au prix d'une plus grande lourdeur d'écriture. Ceci correspond à la philosophie du langage, qui privilégie la facilité de maintenance et la fiabilité par rapport à la facilité de conception initiale. Nous nous permettrons d'insister sur ce point, car la question de l'héritage multiple fait l'objet d'un vif débat jusque dans la communauté POO traditionnelle, certains y voyant la panacée, d'autres un danger permanent. Il est certain que si l'on demande s'il est possible, en Ada 95, de dériver un type directement de plusieurs autres à la fois, la réponse est «non». Mais en fait, l'héritage multiple, comme tout autre outil fourni par un langage d'ailleurs, n'est qu'un moyen au service de besoins. Si l'on demande si Ada 95 répond aux besoins que satisfait l'héritage multiple dans d'autres langages, la réponse est «oui», par ses moyens propres, qui conservent au langage ses points forts que sont la sécurité, la portabilité, l'efficacité et l'indépendance vis-à-vis des représentations machine.

    Une autre originalité d'Ada pour la classification est que le mécanisme du langage ne repose absolument pas sur l'utilisation de pointeurs cachés ou non. L'utilisateur reste libre de définir des types à sémantique de valeur aussi bien qu'à sémantique de référence. De plus, de nouveaux outils tels que les autopointeurs et les pointeurs généralisés permettent de définir des relations subtiles entre classes. Il est possible de définir des objets actifs, et même des objets distribués. On peut donc dire non seulement qu'Ada supporte entièrement les méthodes par classification, mais aussi que les nouveaux outils qu'il apporte sont de nature à faire progresser les méthodes par classification elles-mêmes.

    Classification ou composition?

    modifier

    L'approche objet conduit à une réorganisation complète de la structure des programmes; elle aura donc une profonde influence sur toutes les étapes du cycle de vie du logiciel. Un certain nombre de ses avantages proviennent de l'approche objet elle-même, que celle-ci soit ensuite organisée par composition ou par classification[1]. Nous allons maintenant essayer de dégager les points communs aux deux approches, et aussi ce qui les différencie.

    1. Ce qui n'empêche pas les partisans de chacune des méthodes de s'en attribuer le mérite exclusif.

    Avantages communs aux deux approches

    modifier

    Les méthodes orientées objet conduisent à des programmes plus lisibles et plus compréhensibles : les objets concrets constituent finalement ce que le programmeur connaît le mieux. Une approche «objet» sera plus proche du domaine du monde réel, donc plus facile à appréhender, que les approches fonctionnelles. Les partisans de la classification comme ceux de la composition comparent d'ailleurs toujours leurs méthodes à l'approche fonctionnelle, car c'est par rapport à elle que le bénéfice est le plus grand.

    L'objet inclut toutes les valeurs et opérations nécessaires et suffisantes pour le caractériser; il constitue donc une unité à forte cohésion et faible couplage. Pour déterminer les caractéristiques d'un objet, le programmeur est guidé par les caractéristiques «absolues» de l'objet réel que modélise l'objet informatique, et non par les besoins d'une application particulière; ces caractéristiques sont donc indépendantes de l'utilisation qui en est faite dans le cadre d'un projet informatique particulier. On obtient ainsi des unités facilement réutilisables. Tout nouveau programme partageant avec un programme précédent certaines notions du monde réel pourra réutiliser les modules déjà développés. Nous discuterons cependant de ce point de façon plus approfondie par la suite, car les deux approches ne favorisent pas le même genre de réutilisabilité.

    L'objet regroupe les différents aspects d'une abstraction; cette encapsulation est caractéristique de toutes les démarches «objet». On obtient ainsi une meilleure localisation, c'est-à-dire que les endroits où il est nécessaire d'intervenir en cas d'évolution du logiciel ou de maintenance sont beaucoup plus faciles à identifier.

    Enfin, il n'y a aucune difficulté à utiliser la méthode pour définir des systèmes parallèles : c'est le comportement naturel de la plupart des objets du monde réel; lorsque vous mettez le gaz sous votre autocuiseur, vous allez faire autre chose et ne vous en occupez plus jusqu'au moment où il siffle pour indiquer qu'il a atteint la température requise. Le parallélisme est une conséquence naturelle de cette approche[1], ce qui rend les méthodes orientées objet particulièrement appropriées à la conception de systèmes parallèles. On dispose ainsi d'une méthode unique pour les systèmes séquentiels et parallèles.

    1. Nous pensons qu'un vrai langage orienté objet se doit de permettre le parallélisme. Ce critère n'est pas retenu par la plupart des apôtres de la programmation orientée objet, car alors ni C++, ni Eiffel, ni la plupart des autres LOO ne pourraient obtenir le label magique... Cette idée fait cependant progressivement son chemin, puisque Java et C# offrent des possibilités de programmation concurrente.

    Réutilisabilité

    modifier

    La réutilisabilité est un peu le monstre du Loch Ness du génie logiciel : tout le monde en parle, mais bien peu la mettent en pratique... Encore faut-il savoir ce que l'on entend par réutilisabilité. On peut dire que l'approche objet, de manière générale, promeut la réutilisabilité, et c'est un argument utilisé par les partisans de la composition comme de la classification. La raison en est simple : les modules correspondant de façon bijective aux objets du monde réel, le fait qu'un nouveau logiciel manie les mêmes objets réels que ceux d'un logiciel existant est une condition suffisante pour permettre la réutilisation des modules correspondants.

    Mais du fait de leurs différences, les deux approches conduisent à des vues sensiblement différentes de la réutilisabilité. L'approche par classification favorise le développement incrémental. Il est facile, à partir d'une conception initiale, de dériver de nouvelles versions comportant des propriétés additionnelles sans pour autant affecter les utilisateurs de l'ancienne version : il suffit de faire une nouvelle classe dérivée de l'ancienne, à laquelle on ajoute les nouvelles fonctionnalités. Si certains aspects de l'ancienne version ne conviennent plus, il suffira de les redéfinir, et seulement eux. On peut donc aisément construire un nouvel objet à partir du code développé pour un objet existant. Il n'est pas nécessaire de concevoir spécifiquement dans un but de réutilisabilité : il s'agit d'une réutilisabilité a posteriori, qui permet de développer des objets «sur mesure» sans avoir à reconcevoir leurs aspects standard ou déjà développés. Le prix à payer pour cette facilité est qu'elle risque de conduire à une prolifération de versions ou de variations à partir d'une base donnée, rendant la maintenance difficile, si ce n'est parfois périlleuse, car une modification dans un objet de base sera propagée à un nombre inconnu d'objets dérivés. Si l'on a développé par exemple un objet Menu, il est possible que chacun des N programmeurs d'une équipe décide d'ajuster le Menu à ses désirs propres, conduisant à la nécessité d'en maintenir N+1 versions!

    En approche par composition, on construit des composants logiciels, par analogie avec les composants matériels, dont le but est d'être utilisés comme briques de base dans le développement de logiciels complets. Ceci implique les mêmes contraintes que pour les composants matériels : leur conception doit être soignée, car une modification de leur spécification peut être coûteuse. Ce sera au programmeur de s'adapter aux composants disponibles, au lieu de les adapter à ses besoins propres. La qualité du projet sera fortement conditionnée par la qualité des composants, et ceux-ci devront être conçus dès le début dans un but de réutilisabilité : c'est une vue a priori de la réutilisabilité. En revanche, une fois l'interface bien établie, la maintenance peut corriger, modifier ou adapter l'implémentation en ayant la garantie que cela ne peut avoir aucune conséquence néfaste sur les utilisateurs du composant. L'étanchéité entre spécification et implémentation est totale.

    Il pourrait apparaître que cette approche nuit à l'évolutivité des programmes; il n'en est rien, et le mieux pour s'en convaincre est de comparer avec les composants matériels. Depuis sa création, la spécification (le brochage) du 7400[1] n'a pas bougé! En revanche, son implémentation (sur silicium) a pu changer, et varie même d'un fabricant à l'autre sans que l'utilisateur s'en préoccupe. Des produits extrêmement différents ont pu être ainsi créés en réutilisant les mêmes composants de base.

    Notons pour clore cette discussion sur la réutilisabilité que les deux approches ont un comportement dynamique opposé. L'héritage tend à produire des composants de plus en plus spécialisés (donc de moins en moins réutilisables) au fil du temps et des dérivations. En revanche, la démarche adoptée en composition va consister à construire des composants spécifiques, puis à les généraliser, par exemple en les rendant génériques. Au fil du temps, les modules tendent donc à devenir de plus en plus généraux et réutilisables. On peut dire que la démarche par classification va du général au particulier, alors que la démarche par composition va du particulier vers le général.

    1. Note à l'intention des non-électroniciens : il s'agit d'un circuit intégré comportant quatre portes NAND dans un boîtier – vraisemblablement le circuit le plus utilisé dans les montages logiques.

    Structuration

    modifier

    L'approche objet met en relief l'importance de la structuration globale du projet, que nous avons appelée la «topologie de programme». Mais cet aspect est lié à la méthode choisie. On ne sera donc pas étonné de trouver des structures totalement opposées lorsque l'on change de méthode. Comme les différents critères de décomposition sont ce qui différencie les méthodes, des éléments de «haut niveau» pour une méthode seront considérés comme de «bas niveau» pour une autre, et inversement. Un changement de méthode aboutit à un retournement de la structure même du projet, comme une chaussette que l'on retourne. Il n'empêche que les instructions de base se retrouvent quasiment identiques quelle que soit la méthode employée. Ce qui change, c'est la façon dont elles sont organisées, regroupées entre elles.

    Nous discutions ainsi avec des ingénieurs chargés d'un logiciel de contrôle de lancement de fusée. Quoi de plus séquentiel et impératif qu'un compte à rebours? Ils avaient donc fait une analyse structurée, où le compte à rebours était l'élément de premier niveau de leur analyse. Dans une approche objet, on commencerait par identifier les éléments principaux intervenant dans le problème : le pas de tir, le centre de contrôle, etc. On finirait par retrouver le compte à rebours, mais seulement comme un détail d'implémentation du système de lancement, et non plus comme le point de départ de la conception.

    De même, les encapsulations qui sont caractéristiques des approches objet en général ne regrouperont pas les mêmes éléments selon que l'on travaille en composition ou en classification. Supposons par exemple que nous voulions représenter un carré avec des propriétés graphiques et mathématiques. En composition, nous en ferions un objet autonome, qui regrouperait des objets de plus bas niveau, l'un décrivant les propriétés graphiques, l'autre les propriétés mathématiques; d'autres objets similaires réutiliseraient ces mêmes sous-objets, mais toutes les propriétés de l'objet «carré» seraient regroupées dans un seul module. Inversement, en classification, le carré hériterait certaines propriétés de la classe des objets graphiques, et d'autres de la classe (ou de la facette) des objets mathématiques : ce seraient alors les propriétés communes à plusieurs objets similaires qui seraient regroupées. Ainsi, les propriétés d'une vue d'un objet sont regroupées en composition, alors qu'elles sont réparties sur une ou plusieurs branches du graphe en classification; inversement, les propriétés communes à plusieurs objets sont dupliquées en composition, alors qu'elles sont factorisées en classification (Figure 19).

    Ceci montre bien qu'il n'existe pas une méthode d'organisation absolue, mais des méthodes orthogonales... et pas seulement en informatique. Si par exemple je désire trier des documentations publicitaires, je peux les regrouper par marques ou par types de produits. Chaque fabricant fournissant plusieurs produits, l'une ou l'autre des classifications peut se révéler plus favorable. Si je cherche souvent le catalogue d'un fournisseur, il vaudra mieux trier par marques, alors que si je recherche tous ceux qui peuvent fournir des éditeurs syntaxiques, il vaut mieux trier par type de produit. C'est en fonction de l'utilisation souhaitée et des diverses contraintes du projet que l'on choisira l'une ou l'autre des méthodes.

     
    Figure 19 : Regroupement des propriétés en composition et classification
    Figure 19 : Regroupement des propriétés en composition et classification

    Complexité

    modifier

    Nous avons vu (paragraphe La COO par composition) que le graphe d'une conception par composition était non transitif. Si nous ajoutons un objet à la conception, il dépendra conceptuellement d'un certain nombre de voisins immédiats; si en moyenne nous avons K dépendances, ajouter un élément dans un graphe à N éléments augmentera la complexité de dépendance de K (K sera typiquement compris entre 2 et 10; au-delà, il faut se poser des questions sur la conception). La complexité de dépendance totale du graphe croîtra donc comme KxN. Dans un graphe de classification, un objet dépend transitivement de tous ses ancêtres. Le nombre de dépendances d'un objet est donc proportionnel au nombre total N d'objets dans le graphe. Bien sûr, aucun objet ne dépend de tout le graphe. Soit k la proportion du graphe dont dépend en moyenne l'objet (k est toujours bien inférieur à 1). Ajouter un objet ajoute kxN dépendances, donc la complexité totale du graphe croîtra comme kxN². Il est certain que pour N pas trop grand, kxN² sera inférieur à KxN. Dans ce cas, la complexité de dépendances engendrée par la classification sera inférieure à celle de la composition. Mais lorsque N croît, et ceci quelles que soient les valeurs de k et K, tôt ou tard kxN² dépassera (et même de beaucoup) KxN. Autrement dit, pour un petit projet, une structure en classification peut se révéler avantageuse sur le plan de la complexité; mais la taille des projets réels tend à croître rapidement, et fatalement une structure par composition finira pas se révéler préférable. On mesure ici l'effet pervers qui peut se produire lorsqu'une entreprise effectue un projet pilote pour mesurer l'impact possible d'une nouvelle technologie. Un tel projet est par nécessité de taille réduite, et l'on évalue généralement l'effet sur des gros projets par extrapolation linéaire. Or rien ne dit que la croissance de la complexité est linéaire, et nous avons vu que dans le cas de la classification elle ne l'est pas. Les conclusions tirées du projet pilote risquent donc de n'être absolument pas applicables à un projet de taille réelle.

    Avantages et inconvénients de la composition

    modifier

    La composition conduit à une hiérarchie en niveaux d'abstractions qui se prêtent bien à une approche descendante selon des couches logicielles étanches. Elle promeut le développement de composants logiciels standard réutilisables. En revanche, des modifications des spécifications des composants de base peuvent avoir des répercussions importantes en termes de recompilation sur l'ensemble des projets. Il importe donc que ces composants soient stabilisés avant d'être utilisés à grande échelle. En pratique, il apparaît qu'après une période d'évaluation, les spécifications des composants tendent à se figer. Une fois cet état atteint, de nombreuses applications peuvent les utiliser. Si un besoin de modification se fait sentir, il faut apprécier s'il est préférable de modifier un composant existant ou d'en développer un nouveau sur des bases légèrement différentes. Enfin il peut être plus intéressant de modifier la conception d'un programme pour l'ajuster aux composants existants plutôt que de chercher à créer de toutes pièces un composant «parfait». Cet état d'esprit est malheureusement étranger à la plupart des développements logiciels, alors qu'il est universellement accepté dans le monde des composants matériels.

    Rigoureuse, abstraite, la composition exige plus d'effort de réflexion de la part des développeurs dans les phases initiales de conception; mais n'était-ce pas là l'un de ses buts? Les bénéfices s'en font sentir après. Réflexion d'un utilisateur (de la société Stratégies, développeur du logiciel CADWIN) : «Nous avons eu du mal en phase de spécification, mais dès qu'on arrive à la maintenance, on se régale.»

    Les objets sont faciles à maintenir : un objet rassemblant en un seul module toutes les opérations logiquement reliées, l'endroit d'une modification ou d'une réparation est entièrement défini dès lors que l'objet en cause est identifié; de plus, l'étanchéité des implémentations vis-à-vis des spécifications fait que l'on peut garantir qu'une intervention dans une implémentation n'aura pas d'effet fâcheux sur le reste du programme. Une demande de modification des spécifications au cours du développement d'un projet n'aura de conséquences que sur le module correspondant à l'objet du monde réel sujet à modification.

    Il est difficile d'évaluer l'impact économique d'une méthode, car de nombreux autres facteurs peuvent intervenir. Par contre, des études ont été conduites pour mesurer la rentabilité effective de l'utilisation d'Ada (83); on citera en particulier l'étude de Reifer [Rei87, Rei89], portant sur 41 projets terminés, totalisant 15 millions de lignes de code; 30 d'entre eux utilisaient la conception orientée objet par composition comme méthode d'analyse détaillée. Parmi les résultats intéressants de cette étude, on notera que la répartition conception/développement/mise au point, traditionnellement 40/20/40, était passée à 50/15/35, avec une diminution moyenne de 25% du taux d'erreurs dans les programmes. L'étude ne cherchait pas à différencier ce qui venait seulement de la méthode ou seulement du langage, car il est certain que les bénéfices ne peuvent être obtenus que par la conjonction de la méthode et d'un langage approprié.

    En résumé, la composition nécessite un effort de réflexion et d'analyse intense; elle se prête au développement de composants logiciels standard, immuables dans leur spécification, mais dont l'implémentation peut être remise en cause à tout moment sans perturber les utilisateurs, suivant le modèle des composants électroniques. Moins évolutive que d'autres méthodes en phase de conception, elle permet de mieux maîtriser la phase de maintenance : une modification d'une implémentation n'a aucune répercussion sur les modules utilisateurs; une modification d'une spécification a des conséquences limitées, et dont on peut déterminer l'étendue a priori.

    Avantages et inconvénients de la classification

    modifier

    L'approche par classification conduit à un regroupement dans un même module de tous les aspects communs à différents objets. Le principal intérêt de cette méthode réside donc dans la factorisation des propriétés communes. En revanche, le graphe de dépendance est transitif, c'est-à-dire que les différentes propriétés d'une instance d'objet se trouvent réparties sur toute une branche du graphe.

    Cette méthode favorisera la programmation par adaptation de logiciels, c'est-à-dire qu'il sera facile, à partir d'un objet qui correspond presque aux besoins du programmeur, de dériver un nouvel objet en ne récrivant que le différentiel, c'est-à-dire la différence entre l'objet souhaité et l'objet existant. En revanche, une modification d'un objet ancêtre modifiera le contexte et la sémantique de tous ses descendants. La classification permet donc de minimiser l'effort de réécriture en phase de développement, lorsque la définition des objets évolue rapidement; en revanche, les conséquences d'une modification en phase de maintenance peuvent s'étendre à une partie importante, et non connue a priori, du projet.

    De façon générale, les méthodes par classification apportent une grande souplesse lorsque le domaine est mal connu, les concepts mouvants ou en cours de raffinement. Elles permettent bien de modéliser des hiérarchies de connaissance, et souvent de manipuler commodément les entités des applications graphiques. La liaison dynamique permet de pallier l'absence de parallélisme de beaucoup de langages (c'est pourquoi elle est utilisée dans les systèmes de fenêtrage comme XWindow ou MacApp). Ces méthodes se prêtent en revanche assez mal à une approche descendante de la conception, et d'ailleurs beaucoup de partisans de la classification prônent le développement ascendant, en partant des bibliothèques de classes existantes. Une telle façon de faire, si elle permet de développer rapidement des prototypes lorsque l'on dispose d'une vaste bibliothèque de composants (cas des environnements graphiques), devient malheureusement vite impraticable lorsque la taille des logiciels atteint l'échelle industrielle.

    On peut enfin se poser des questions sur la notion même de classification comme moyen de conception. Si toutes les sciences utilisent la classification, c'est pour répertorier des éléments préexistants. La problématique est toute différente lorsqu'il s'agit de concevoir un système nouveau. On cherchera en vain dans l'ingénierie électronique, qui est pourtant la branche qui se rapproche le plus de l'informatique, un mécanisme pouvant se comparer à l'héritage pour la conception de systèmes.

    Composition et classification : ne peut-on les fusionner?

    modifier

    Parvenu à ce point, il est logique de se demander s'il ne serait pas possible de définir une méthode incorporant à la fois les concepts de composition et de classification pour jouir des avantages propres à chaque approche.

    Nous ne saurions trop insister sur le fait que ces deux notions sont absolument orthogonales. [Mas89] utilisent d'ailleurs un exemple de composition pour montrer le cas (selon eux) où il ne faut pas utiliser l'héritage. Par exemple, un ordinateur est composé de transistors; il n'hérite pas pour autant des propriétés des transistors; que signifierait «polariser un ordinateur»? Cette orthogonalité se traduit par le fait que les graphes de dépendance correspondant à chacune des structures possèdent des propriétés radicalement différentes : transitifs avec une complexité quadratique pour la classification, non transitifs avec une complexité linéaire pour la composition.

    Les modes de raisonnement sont totalement différents[1], et même dans la vie courante des personnes différentes tendront à adopter naturellement l'une ou l'autre forme de description. Par exemple, nous participions un jour à une réunion de travail où nous disposions sur la table de jus de fruit concentré, que l'on pouvait diluer soit avec de l'eau plate, soit avec de l'eau gazeuse, permettant à chacun de préparer la boisson de son choix. Cette description est en fait une modélisation par composition. En classification, nous aurions dit que nous disposions de boisson à l'orange, subdivisée en orange piquante et orange plate...

    La dichotomie entre les deux approches n'est cependant pas totale. Les langages orientés objet offrent nécessairement des possibilités de composition, et des langages purement «compositionnels», tels que Ada 83, offraient déjà, par les types dérivés, un mécanisme qui avait toutes les propriétés d'un héritage (statique), et même une forme de polymorphisme par les types à discriminants. Ces avancées vers l'autre domaine permettent à ces langages de récupérer quelques avantages spécifiques de «l'autre monde» sans sacrifier leur philosophie propre. Néanmoins, chacune des approches accordera la place dominante à la composition ou à la classification.

    Nous verrons que de nombreuses méthodologies cherchent à exprimer simultanément des dépendances de type «composition» (agrégations) et de type «classification» (héritage). Ceci est dû à l'importance, à notre avis exagérée, accordée par de nombreux partisans de l'orienté objet à l'héritage, et comporte un risque inhérent sur le plan de la complexité : en superposant un graphe de composition et un graphe de classification, on risque d'obtenir une complexité globale qui serait la somme des deux complexités, si ce n'est pire.

    Plutôt que de vouloir gérer simultanément les deux approches, il peut être plus raisonnable de les faire cohabiter indépendamment, c'est-à-dire de développer certaines parties d'un projet en composition, et d'autres en classification. Selon le profil de chaque partie, on choisirait la méthode la plus appropriée. Dans ce cas, il est tout de même nécessaire de définir un principe d'organisation global pour tout le projet, et il sera toujours plus facile de choisir la composition d'abord. En effet, il est possible d'incorporer dans un graphe de composition des branches qui, localement, sont des graphes d'héritage. Cela vient du fait que par définition, on ignore en composition comment sont implémentés les objets : si ceux-ci utilisent l'héritage, cela demeurera invisible aux niveaux supérieurs. Mais la démarche inverse est beaucoup plus difficile, puisque l'héritage suppose la transparence du graphe. Pour prendre une image, si l'on met une boîte transparente dans une boîte opaque, celle-ci reste opaque; alors que si l'on met une boîte opaque dans une boîte transparente, celle-ci n'est plus transparente.

    Ceci ne signifie pas qu'il soit impossible d'utiliser de la composition dans une conception organisée par classification (nous avons déjà dit que c'était nécessaire), mais que l'utilisation de la composition rompt le mécanisme général de la classification, alors qu'une utilisation locale de l'héritage dans une structure par composition n'a pas d'impact sur la philosophie générale de la conception.

    Nous pensons donc que même si l'on veut faire coopérer les deux approches, une méthode se doit de choisir une direction principale qui subordonne l'autre; les parties développées avec l'«autre» philosophie doivent rester localisées. Et dans ce cas, l'approche par composition d'abord bénéficie d'un avantage, puisqu'elle est moins perturbée par des classifications locales que dans la démarche inverse. Dans la cinquième partie de cet ouvrage, nous proposerons une telle méthode donnant la priorité à la composition; la classification n'y sera utilisée que là où elle apporte un gain notable, et toujours dans les couches basses de la conception, afin de limiter la complexité.

    Bien entendu, il ne faudrait pas conclure de cette discussion que la classification doive être rejetée absolument : elle est certainement très performante dans le cadre de développements rapides, de complexité moyenne et/ou à faible durée de vie. Chaque projet possède ses contraintes propres, et le point important est de trouver, pour chaque type de développement, la méthode la plus adaptée; la quatrième partie de cet ouvrage présentera quelques critères de choix permettant de déterminer la méthode adaptée à un projet en fonction de ses caractéristiques.

    1. Ce qui fait souvent ressembler les discussions entre partisans de chacune des méthodes à des dialogues de sourds.

    Exercices

    modifier
    1. Analyser en composition l'exemple de la paye qui a servi à illustrer la classification. Comparer l'organisation des solutions.
    2. Analyser en classification l'exemple du cahier de comptes qui a servi à illustrer la composition. Comparer l'organisation des solutions.
    3. Faire un tableau de l'évolution de la complexité d'un programme en fonction du nombre N de modules (cf. paragraphe Complexité) en classification et en composition. On prendra k = 0,1 et K = 5 et N = 10, 20, 50, 100 et 200. Essayer ensuite avec k=0,2 et K = 4 ou 6. Conclusion? Si l'on estime qu'un module fait en moyenne 200 lignes de programme, à partir de quelle taille vaut-il mieux utiliser la composition?

    Les méthodes entités-relations

    modifier

    Principes des méthodes entités-relations

    modifier

    Les principes des méthodes entités-relations ont été établis par Chen [Chen76], à une époque où l'on ne parlait pas encore d'objets. En fait, la notion d'«entité» est très proche de la notion d'objet, et les publications plus récentes se référant à ces méthodes se considèrent comme «orientées objet». Vu l'importance de ces méthodes, nous avons préféré leur consacrer un chapitre séparé, mais compte tenu de nos définitions précédentes, on peut aussi les considérer comme une forme particulière de méthode objet, où la dimension «verticale» est souvent absente. Tous les critères des autres méthodes (composition, classification) peuvent en effet se mettre sous forme de «relations» particulières, ce qui aboutit à un «aplatissement» total de la conception.

    Un schéma d'entités-relations représente une description (statique) d'un système réel pour lequel on souhaite développer une application informatique. Il s'agit donc plutôt d'une méthode d'analyse que d'une méthode de conception comme celles que nous avons vues précédemment; autrement dit, elle décrit plus le but à atteindre que le moyen d'y parvenir. Il faudra donc par la suite développer une conception, et donc transformer le modèle en un autre. Ce problème a donné lieu à de nombreuses publications; on consultera par exemple avec intérêt [Bar92] qui propose une méthode pour passer d'un modèle entités-relations (REMORA) à un modèle objet compositionnel en Ada.

    Les entités représentent des classes d'objets, munis d'attributs. Le modèle sous-jacent de la plupart des méthodologies associées est celui des bases de données relationnelles; une entité correspond dans ce cas à une table dont chaque ligne correspond à une instance de l'entité, et chaque colonne à un attribut. Mais on peut également voir une entité comme un type article dont les différents composants correspondent aux attributs.

    Ces entités (représentées par des rectangles) sont reliées par des associations (représentées par des lignes entre les rectangles) qui expriment les relations logique entre les entités; les relations sont marquées par un nom ou un verbe (dans un losange – ou un ovale selon la méthode) qui exprime la nature de la dépendance (Figure 20)[1]. Les relations sont conceptuellement réciproques; selon les conventions, on indique ou non la relation dans les deux sens (la réciproque de «passe» dans l'exemple de la figure 20 serait «est passée par»).

    À faire... 

    Passer en notation UML, ou au moins justifier cette notation

     
    Figure 20 : Schéma d'entités-relations
    Figure 20 : Schéma d'entités-relations

    On indique la cardinalité de l'association, c'est-à-dire le nombre d'instances d'objets situés d'un côté de la relation susceptibles de dépendre d'une instance d'objet situé de l'autre côté, sous la forme d'une paire de nombres [minimum:maximum]. Il existe de nombreuses variantes de ces diagrammes. Le schéma de la figure 20 représente une partie des éléments entrant dans un système de gestion des commandes. Les entités sont Personne, Commande, Paiement et Société. Une personne peut passer plusieurs commandes, mais n'est entrée dans le système que si elle en a passé au moins une : c'est ce qu'indique la cardinalité [1:n] au départ de la relation passe; inversement, une commande ne peut être passée que par une seule personne, d'où la cardinalité [1:1] en sens inverse. Une personne appartient (mais pas forcément) à une société (cardinalité [0:1]), mais une société comporte plusieurs personnes (cardinalité [1:n]). Un paiement règle une seule commande (cardinalité [1:1]), et une commande n'est associée à un règlement (et un seul) que lorsque le règlement a été effectué (cardinalité [0:1]). Enfin, une société peut garantir 0, 1 ou plusieurs commandes (cardinalité [0:n]), mais une commande ne peut être garantie que par une société au plus (cardinalité [0:1]).

    1. Ces notations sont celles initialement définies par ces méthodes. Elles sont antérieures à UML, et d’ailleurs à l’origine de certaines de ses représentations.

    Ada et les méthodes entités-relations

    modifier

    Au niveau du langage, il serait bon de pouvoir représenter aussi directement que possible les éléments qui ont été définis par la conception. La représentation des entités ne pose pas vraiment de problème : ce sont des données classiques. En revanche, la notion de relation, qui était très simple au départ, s'est progressivement enrichie, au fur et à mesure du développement des méthodes. On a ajouté progressivement des attributs de relation, des conditions, etc. Si l'on peut facilement considérer que les entités sont assimilables à des objets (au sens des méthodes orientées objet), doit-on considérer les associations comme des objets?

    La logique voudrait évidemment que l'on réponde non, car une relation n'est pas de même nature qu'un objet. Cependant, il serait logique d'avoir des types de données pour représenter les associations. Dans un langage purement orienté objet, où la classe est le seul moyen de représenter des données, on se trouve ici face à une contradiction. En Ada en revanche, on peut très bien utiliser des types de données pour représenter des associations sans pour cela devoir les assimiler à des objets.

    [Jea93] a présenté différentes façons de modéliser les associations en Ada 83. Nous les présentons brièvement ci-dessous, en y ajoutant les nouvelles possibilités d'Ada 95. Il convient d'abord de noter que la notion d'«association» est extrêmement vague et peut servir à exprimer, dans un cadre unique, toutes sortes de dépendances entre les entités. Ainsi, on peut avoir des relations «est composé de» (agrégation) exprimant qu'une entité est composée d'autres entités, et des relations «est un» (héritage) exprimant qu'une entité est une spécialisation d'une autre. Dans ces cas, l'implémentation est triviale : un article composé des différents éléments pour l'agrégation, un type (étiqueté) dérivé pour l'héritage.

    L'agrégation peut poser un problème; dans le modèle entités-relations, les relations sont censées être réciproques. Par conséquent, si « A contient B », on doit pouvoir dire « B est contenu dans A ». L'utilisation d'un simple type article ne permet pas de traduire cette relation inverse (d'utilisation rare, reconnaissons-le). Si elle est nécessaire, on peut l'obtenir au moyen d'un autopointeur :

    type A;
    type B (Englobant : access A) is
    	record
    		...
    	end record;
    type A is limited
    	record
    		Composant_B : B (A'Access);
    		... autres composants
    	end record;
    

    La relation « A contient B » correspond à l'imbrication physique des champs de l'article, et la relation « B est contenu dans A » est obtenue par le pointeur Englobant. Au risque de lasser le lecteur, force nous est de constater qu'Ada est, à notre connaissance, le seul langage permettant ainsi de gérer automatiquement ce type de relation inverse. Pour les autres types de relation, il convient d'analyser soigneusement leur type pour déterminer l'implémentation appropriée; [Jea93] présente d'ailleurs une taxonomie des relations.

    Si la relation est unidirectionnelle, on pourra utiliser une référence, sous forme d'un composant pointeur si l'entité associée est susceptible de changer dans le temps, ou d'un discriminant pointeur si l'entité associée est unique et non facultative (traduisant ainsi un couplage beaucoup plus fort entre les entités). Dans le cas des relations où la symétrie est importante, on pourra utiliser des pointeurs doubles. Si la relation est multiple (cardinalité supérieure à 1), on pourra définir une table (ou une liste chaînée) représentant les éléments participant à l'association. Il est facile de faire un générique fournissant la gestion de ces tables d'associations.

    Noter que certaines méthodes ajoutent des attributs aux relations; on utilisera dans ce cas le même principe, mais l'association sera représentée par un article contenant les attributs, ainsi éventuellement que la table d'associations.

    On voit à travers ces quelques exemples qu'il n'y a pas de moyen unique pour représenter toutes les formes d'associations, mais que c'est la variété des structures de données et autres outils fournis par Ada qui permet de représenter toutes les formes d'associations.

    Exercices

    modifier
    1. Représenter le schéma d'entités-relations de l'exemple de paye du paragraphe Exemple en classification.
    2. Les schémas d'entités-relations sont-ils appropriés aux problèmes de gestion? Et aux problèmes de temps réel? Défendez votre réponse en fonction des contraintes de chacun de ces domaines.

    Méthodologies

    modifier

    Nous avons présenté les différentes méthodes, c'est-à-dire les grands principes permettant d'organiser les développements de logiciels. Mais des principes ne sont pas suffisants en pratique : il faut également des modalités d'application, des directives organisées qui expliquent concrètement comment conduire un développement logiciel. C'est le but des méthodologies.

    Certaines méthodologies étaient, au moins au départ, fondées sur une seule méthode. Mais souvent, la méthode unique n'est pas suffisante pour couvrir les contraintes souvent contradictoires d'un développement. Aussi de nombreuses méthodologies empruntent-elles à plusieurs méthodes, mais privilégient généralement l'une d'entre elles : c'est la direction principale de la méthodologie. Après les principes généraux, nous présenterons quelques-unes des méthodologies les plus courantes, selon leur direction principale.

    La mode du tout orienté-objet et d’UML tend à créer une sorte de pensée unique. Et pourtant, l’informatique couvre aujourd’hui des domaines tellement variés qu’il semble difficile de croire qu’un seul processus puisse répondre à tous les besoins. C’est pourquoi nous présentons ci-dessous certaines méthodologies anciennes, qui ne sont parfois plus guère utilisées. Nous pensons qu’il est utile que le lecteur soit convaincu qu’il existe plusieurs façons de faire, et que le rôle de l’ingénieur est avant tout de déterminer quelle technologie est la plus adaptée à son besoin, plutôt que d’adopter aveuglément la seule démarche dont on parle dans les journaux.

    Généralités, démarche et notation

    modifier
    À faire... 

    Parler d’UML à ce propos!

    La plupart des méthodologies comportent deux éléments principaux : une démarche et une notation [Rum94]. La démarche définit le processus de conception; elle décrit les différentes étapes permettant de passer de l'énoncé d'un problème à sa solution. La notation décrit un ensemble de symboles permettant d'exprimer la conception. Son but est de guider la démarche et de produire un document de référence pour la maintenance. Enfin, la plupart des méthodologies sont dotées d'outils supports facilitant l'application de la démarche et automatisant l'expression de la notation, notamment lorsque elle est essentiellement graphique.

    Le point le plus fondamental d'une méthodologie est la démarche. Le point le plus visible est la notation. Il importe donc de toujours se rappeler que la notation doit être subordonnée à la démarche : trop souvent, nous avons pu constater que l'application d'une méthodologie se bornait à utiliser la notation, ce qui peut conduire à des échecs, car les outils supports ne permettent alors pas d'exprimer l'intention réelle des concepteurs.

    Une méthodologie, ce n'est pas seulement tracer des flèches entre des boîtes!

    Enfin, certaines notations, initialement définies par des méthodologies, ont été adoptées par d'autres, mais généralement avec des «adaptations». Il importe donc de bien connaître la signification précise des symboles utilisés pour la méthodologie considérée, car, sous des apparences similaires, il peut y avoir des différences d'interprétation importantes.

    Pendant longtemps, chaque méthodologie avait ses notations propres. Certaines d’entre-elles ont parfois été adoptées par d'autres méthodologies que celle d’origine, mais généralement avec des «adaptations». Ceci a conduit à des difficultés d’interprétation des symboles courants (formes des boîtes et des flèches).

    UML (Unified Modeling Language, langage de modélisation unifié) est né du besoin d’unifier les différentes notations. Il est apparu en 1994, quand J. Rumbaugh rejoignit Rational, où G. Booch était responsable des méthodologies. En 1995, I. Jacobson rejoignit l’équipe, et après plusieurs itérations, UML 1.0 fut publié en Janvier 1997. Plusieurs fabricants firent alors équipe avec Rational, ainsi que l’Object Management Group. En Septembre 1997, les nouveaux partenaires produisirent UML 1.1, première version largement diffusée de la notation.

    La méthode continua d’évoluer jusqu’à la version 1.4. Courant 2001, les membres de l’OMG décidèrent que le temps d’une mise à jour majeure était venu, ce qui produisit UML 2.0. À l’heure où nous écrivons ces lignes (2004), UML 2.0 est stabilisé et la plupart des documents sont disponibles, mais il reste encore certains éléments en cours de parution.

    Il importe de comprendre qu’UML n’est qu’une notation, pas une méthodologie. Hélas, bien des gens ignorent le sage précepte qui conclue le paragraphe précédent, et «font de l’UML» comme méthodologie. Ceci ne veut strictement rien dire; on peut utiliser une méthodologie qui utilise UML comme représentation, mais l’important est la méthodologie, pas la représentation.

    UML se voulait indépendant des langages de programmation; en pratique, surtout avant la version 2, il était directement calqué sur C++. On y retrouve des notions comme la distinction entre membres publics, protégés, et privés, qui n’existent que dans ce langage. En revanche, la notion de module élémentaire, comme les paquetages Ada, est difficile à représenter (les paquetages UML correspondent plutôt aux namespace de C++). UML 2 a introduit la notion de décomposition hiérarchique (qui est à la base de la méthode HOOD), permettant enfin une décomposition descendante; on peut s’étonner qu’il ait fallu tant de temps pour introduire une notion aussi fondamentale.

    UML définit 13 formes de diagrammes différents, qu’il serait trop long de détailler ici; de nombreux ouvrages sont à la disposition du lecteur intéressé. Mais il est important de comprendre qu’aucune méthodologie ne saurait utiliser tous les diagrammes, pas plus qu’aucun programme n’utilise jamais toutes les possibilités de son langage de programmation. Le but d’UML est de fournir une infrastructure de description, afin que chaque méthodologie y puise les représentations qui lui sont nécessaires.

    Méthodologies en programmation structurée

    modifier

    La principale méthodologie formalisée utilisée de façon industrielle est SA/SD (Structured Analysis/Structured Design), définie initialement par Yourdon et Constantine [You79] et enrichie par DeMarco, Page-Jones... Elle a introduit une notation, largement reprise par la suite, pour exprimer les flots de données et leurs transformations : un cercle représente un traitement, une flèche un flot de données qui se propage de traitement en traitement. Les éléments à l'origine des données (sources) ou destinataires finals de celles-ci (puits) sont représentés par des rectangles. De plus, les données peuvent être stockées dans des réservoirs de données représentés par deux traits parallèles. Les diagrammes résultant sont appelés «diagrammes en flots de données», ou DFD (Data Flow Diagram). La figure 21 en donne un exemple.

    Sur ce diagramme, nous voyons que le client passe une commande. La saisie de la commande donne naissance à un ordre de fabrication en cuisine et fournit le prix à payer. Le client effectue un paiement, qui va dans la caisse (un réservoir de données). Cela produit un calcul de monnaie, qui fournit au client la monnaie, en provenance de la caisse. De l'autre côté, l'ordre de fabrication a déclenché la fabrication d'un hamburger à partir de pain et de viande, tous deux provenant du réfrigérateur, autre réservoir de données.

    Ce type de diagramme permet d'exprimer les traitements et les flots de données; il n'exprime pas en revanche les synchronisations. Par exemple, on ne voit pas que le client ne peut fournir le paiement qu'une fois le prix connu. Le diagramme des traitements est complété par un dictionnaire de données qui décrit, de façon textuelle, les caractéristiques des données échangées. Bien entendu, ce sont les traitements qui constituent l'unité principale de structuration, et le découpage en couches s'obtient en subdivisant les traitements généraux en traitements plus spécifiques.

     
    Figure 21 : Diagramme de flot de données SA
    Figure 21 : Diagramme de flot de données SA

    La méthode a par la suite été enrichie par Ward et Mellor en fonction des contraintes particulières de la programmation en temps réel pour donner la méthode SART (SA Real-Time) [War85]. En particulier, on considère qu'un système est caractérisé par un état courant, et qu'il subit des transitions qui le font passer d'un état à l'autre. Ceci est décrit par des diagrammes états-transitions, qui ont été repris (avec d'innombrables variantes) dans la plupart des méthodologies.

     
    Figure 22 : Diagramme d'états-transitions
    Figure 22 : Diagramme d'états-transitions

    Sur le diagramme de la figure 22, on part de l'état initial «Arrivée au bureau» pour arriver à l'état «Au travail». Sur l'événement «Appel téléphonique», on passe à l'état «Au téléphone» dont on revient sur l'événement «Fin communication». De même, l'événement «12 heures» fait passer dans l'état «Au déjeuner» dont on sort par l'événement «14 heures». Enfin l'événement «18 heures» fait passer à l'état final, «Quitter bureau».

    Méthodologies en composition

    modifier

    La méthode de Booch

    modifier

    Cette méthode, qui est à la base des autres méthodes orientées objet par composition, a été définie par G. Booch, dans son livre Ingénierie du logiciel avec Ada [Boo88]. Son but était de fournir une méthode adaptée à Ada, mais ceci ne l'empêche pas d'être utilisable avec d'autres langages. Depuis, Booch a défini une autre méthode (Rose, [Boo91]) qui prend en compte les aspects de classification; le terme «méthode de Booch» tend donc à devenir ambigu.

    Pour mettre en œuvre sa méthode, Booch a défini un certain nombre d'étapes permettant de guider la démarche pour obtenir de «bons» objets. Ces étapes ont été reprises avec plus ou moins de modifications ou de précisions dans la plupart des méthodologies par composition.

    • Identifier les objets. Cette première étape vise à identifier les objets du monde réel que l'on voudra réaliser. Pour cela, on doit identifier les propriétés caractéristiques de l'objet.

    Cette étape est bien entendu celle qui demande le plus de talent et d'expérience personnelle au programmeur; un moyen relativement informel pour identifier les objets consiste à faire une description informelle (en français) du problème. On pourra déduire les bons candidats objets des noms utilisés dans cette description, et leurs propriétés des adjectifs et autres qualifiants.

    • Identifier les opérations. On cherchera ensuite à identifier les actions que l'objet subit et provoque. Les verbes utilisés dans la description informelle précédente fournissent de bons indices pour l'identification des opérations. C'est également à cette étape que l'on pourra définir les conditions d'ordonnancement temporel des opérations, si nécessaire.
    • Etablir la visibilité. L'objet étant identifié par ses caractéristiques et ses opérations, on définira ses relations avec les autres objets; autrement dit, on établira quels objets le «voient» et quels objets «sont vus» par lui. Autrement dit, on insérera alors l'objet dans la topologie du projet.
    • Etablir l'interface. À partir de là, on définit l'interface précise de l'objet avec le monde extérieur. Cette interface définit exactement quelles fonctionnalités seront accessibles, et sous quelle forme. Cette étape doit s'écrire de préférence à l'aide d'une notation formelle; une spécification de paquetage Ada constitue une notation tout à fait acceptable.
    • Implémenter les objets. La dernière étape consiste bien entendu à implémenter les objets en écrivant le code correspondant aux spécifications dans un langage de programmation. Cette étape peut nécessiter la définition de nouveaux objets de plus bas niveau d'abstraction, ce qui provoque l'itération de la méthode.
     
    Figure 23 : Diagrammes de Booch
    Figure 23 : Diagrammes de Booch

    Booch a introduit des diagrammes, représentés en figure 23, généralement utilisés pour représenter les entités Ada. Ils servent à construire des graphes qui, comme nous l'avons vu dans le chapitre sur la composition, sont essentiellement structurels et dont les flèches expriment les liens de dépendance entre entités. Elles correspondent donc aux clauses with du programme Ada.

    La méthode HOOD

    modifier

    La méthode HOOD (Hierarchical Object Oriented Design) [Ros97] a été développée par CISI Ingénierie, Matra et la société danoise CRI pour le compte de l'Agence spatiale européenne. Elle a été choisie parmi quinze méthodes concurrentes pour le développement des logiciels de la station spatiale Columbus et de l'avion spatial Hermès[1] et est largement utilisée aussi bien dans le domaine spatial que dans l'industrie en général. Elle intègre non seulement une méthode de conception, mais également la définition de toute la documentation associée, et permet de faire la liaison avec des méthodes formelles. Elle a été conçue pour être utilisée avec des outils supports qui sont aujourd'hui présents sur le marché; la situation est même concurrentielle : en 1994, six fournisseurs (au moins) étaient présents sur le marché, ce qui fait de HOOD une des méthodes les mieux supportées en termes d'outillage. Plusieurs versions de la méthode sont apparues, suivant l'expérience acquise et les recommandations des utilisateurs (le HUG, HOOD User Group). La première version stable et largement utilisée fut actuelle est la 3.1; elle servit de base à une extension, appelée HRT-HOOD (Hard Real-Time[2] HOOD) pour les logiciels demandant un contrôle précis et prouvable de l’ordonnancement. La version actuelle est la version 4, qui permet notamment d’introduire une dimension de classification dans la conception, mais de façon toutefois très contrôlée, dont les utilisateurs ont estimé qu'elle était suffisamment bien définie pour la geler pendant au moins trois ans, et donc permettre de stabiliser les outils. Une version 4 est actuellement à l'étude, qui pourrait inclure des concepts de classification.

    La méthode elle-même résulte de la fusion des notions de machines abstraites et d'objets. Un projet est donc décomposé en objets qui sont, en principe (les évolutions récentes de la méthode on assoupli ce point), uniquement des machines abstraites. On distingue les objets passifs, s'exécutant séquentiellement et les objets actifs pouvant s'exécuter en parallèle.

    Un objet peut être terminal ou non terminal. Dans ce dernier cas, toutes les opérations fournies sont réalisées par «sous-traitance» à des objets «enfants», conduisant à la structure hiérarchique qui a donné son nom à la méthode. Nous pouvons illustrer cette notion de sous-traitance de la façon suivante : imaginons un objet «téléviseur». Extérieurement, il est muni d'opérations (les boutons) telles que «Marche/arrêt», «Réglage volume» ou «Choisir chaîne». Effectivement, l'utilisateur considère qu'il met en marche «la télévision» ou qu'il règle «la télévision». En fait, le bouton d'arrêt fait partie au niveau de l'implémentation (quand on ouvre la boîte noire) de l'alimentation secteur, alors que le bouton de volume appartient à l'ampli son et le sélecteur au tuner. En termes HOOD, on dirait que la télévision sous-traite ces opérations aux objets enfants correspondants. Ce principe est illustré par la figure 24.

     
    Figure 24 : HOOD et la sous-traitance
    Figure 24 : HOOD et la sous-traitance

    La méthode sépare clairement les différents aspects de l'analyse. En plus de la relation hiérarchique qui décrit la structure du projet, on analyse et on décrit de façon indépendante les aspects impératifs (correspondant à l'exécution séquentielle des opérations, relevant donc d'une analyse en programmation structurée et décrite par du code Ada) et les aspects réactifs (description des interactions entre objets actifs, susceptibles de modélisation par des méthodes formelles telles que les réseaux de Petri, ou utilisation standardisée de formes Ada). Cependant, chacune de ces descriptions est fournie localement, pour chaque objet; on ne trace jamais de flot de données global. Ceci permet de conserver une stricte hiérarchie et l'étanchéité des niveaux d'abstractions.

     
    Figure 25 : Représentation de la télévision par des objets HOOD
    Figure 25 : Représentation de la télévision par des objets HOOD

    La méthode définit un formalisme graphique et un formalisme textuel, décrivant les flux de contrôle, de données et d'exceptions aussi bien que la structure des dépendances logiques. Notre téléviseur peut ainsi être représenté sous forme d'objets HOOD comme sur la figure 25. Le diagramme montre également des relations entre les objets enfants (ici, l'alimentation 12V fournie par l'alimentation aux autres modules), non visibles de l'extérieur. Les flèches d'utilisation peuvent porter des informations de flux de données (les flèches dans un rond) et de flux d'exceptions.

    La méthode est également adaptée à la définition de systèmes distribués, grâce à la notion de nœud virtuel. Un nœud virtuel représente une unité possible de distribution. Une étape de la méthode consiste à attribuer un nœud virtuel à chaque objet de la conception. Les nœuds virtuels sont ensuite eux-mêmes alloués à des nœuds physiques (comme des ordinateurs sur un réseau) est un objet HOOD normal vérifiant certaines contraintes supplémentaires. Les nœuds virtuels ne peuvent apparaître qu'aux premiers niveaux de décomposition, et les restrictions qu'ils vérifient permettent de les répartir sur plusieurs ordinateurs reliés par un réseau. On sépare donc la répartition du système entre une répartition logique et une répartition physique. Ceci permet notamment n'interdit pas d’allouer d'implémenter finalement plusieurs nœuds virtuels sur une même machine : il est ainsi possible d'avancer la conception et même la validation du logiciel indépendamment de lsa répartition physique du système, et même avant d'avoir fait les choix définitifs de matériel.

    On trouvera une description complète de plus de détails sur la méthode HOOD dans [Ros97] par exemple, ainsi qu'un compte rendu d'utilisation dans [Lai89]. La définition officielle de la méthode est donnée dans [Hoo93]].

    1. Les vicissitudes qu'on subies ces projets sont sans rapport avec l'utilisation de la méthode HOOD...
    2. Temps réel dur

    La méthode Mac_Adam

    modifier
    À faire... 

    !!!! Virer Mac_Adam et Buhr?

    Mac_Adam [Rig87,Rig89] est une chaîne complète de développement de logiciels, comportant donc plusieurs facettes. La partie méthodologique est directement inspirée de la COO de Booch, avec une orientation plus spécifique du temps réel. Une première étape permet de définir le contexte du problème et les interfaces externes, conduisant à une modélisation de l'espace de problème. Du rapprochement entre ce modèle abstrait et les contraintes spécifiques du temps réel, on dérive un regroupement en tâches, la définition des protections et la structuration en sous-systèmes.

    En plus de cet aspect purement «méthodologie de conception», la méthode gère le suivi des unités de test et d'intégration, et la documentation. Celle-ci est conforme à la directive 2167 du DoD [DoD87]. Un outil associé permet de suivre les différentes étapes de la méthode jusqu'à la génération de squelettes de programmes.

    La méthode de Buhr

    modifier

    Composition et classification ne sont pas les seules façons d'organiser des objets; la méthode de Buhr [Buh84,Buh89] est une autre méthode de conception «orientée objet», visant principalement la conception de systèmes temps réel. Cette méthode utilise le comportement (behaviour) comme critère de décomposition verticale, selon une technique inspirée de celles utilisées dans le monde de l'automatique. C'est donc une méthode orthogonale à la fois à la composition et à la classification[1].

    Le module de base dans la méthode de Buhr est soit la boîte (box), qui est une unité passive, soit le robot qui est une unité active. Ces deux unités sont appelées globalement des machines. Une machine est une unité de comportement, possédant des ports qui lui permettent de communiquer avec d'autres robots par l'intermédiaire de canaux. Des événements circulent dans ces canaux. Ces notions sont par la suite raffinées sous forme de boutons qui doivent être poussés par un doigt pour déclencher une action. Certains boutons ne peuvent s'activer que sous l'action combinée d'un doigt qui pousse et d'un doigt qui tire (push-pull button).

    Les grandes étapes de l'analyse d'un niveau d'abstraction selon la méthode de Buhr sont :

    • Séparation des comportements. Il s'agit de définir ici les unités de comportement qui seront représentées par des machines individuelles.
    • Définition des protocoles abstraits. Il faut identifier les protocoles selon lesquels les machines vont communiquer entre elles.
    • Définition des intermédiaires. Les canaux peuvent eux-mêmes être des machines relativement compliquées. On va définir ici les machines intermédiaires permettant d'assurer les communications.
    • Définition des protocoles concrets. Il faut définir pratiquement les détails des protocoles, en tenant compte des particularités structurelles des ports correspondants.
    • Définition des agendas. Il faut maintenant spécifier l'ordonnancement temporel des protocoles.
    • Valider le comportement. Reparcourir la définition globale élaborée jusque-là et envisager la réponse du système à différents événements.

    Comme on le voit, le grand intérêt de la méthode de Buhr est de permettre la prise en compte de l'ordonnancement temporel dès les premières étapes de la conception, ce qui en fait un système très efficace pour le développement de logiciels temps réel. La notion de machine intègre bien également les composants matériels, ce qui permet de définir un système (matériel et logiciel) comme un tout, en remettant à plus tard le stade où l'on doit décider de ce qui doit être implémenté par logiciel ou par matériel. En revanche, elle se prête moins bien à une approche descendante par niveaux successifs, ce qui limite sa portée aux systèmes de taille relativement modeste.

    La méthode est supportée par un formalisme graphique, et il existe des outils d'environnement permettant de le mettre en œuvre, aussi bien pour la conception elle-même que pour la vérification automatisée des diagrammes, la simulation temporelle et la génération de squelettes de code.

    1. Cette notion de méthodes orthogonales est explicitement utilisée par Buhr dans [Buh89].

    La méthode Rose

    modifier
    À faire... 

    Remplacer par RUP?

    La méthode Rose [Boo91], la «nouvelle» méthode de Booch, reprend les principes de la méthode initiale, mais y incorpore une dimension de classification. Les étapes sont restées à peu près les mêmes que dans la méthode d'origine, mais les diagrammes se sont enrichis pour décrire différentes formes de dépendance entre objets : utilisation, instanciation, héritage, métaclasses... Ces formes sont cependant limitatives (au contraire des méthodes entités-relations). La méthode étant moins orientée Ada que précédemment, on distingue les diagrammes de classes des diagrammes de modules.

    La méthode travaille par niveaux d'abstraction dans la direction verticale; en particulier, on cherche à définir des sous-systèmes étanches.

    Méthodologies en classification

    modifier

    On pourrait s’attendre à ce que la vogue de la classification ait fait naître de nombreuses méthodes de conception, ou au moins qu’une méthode s’impose largement. Or il n’en est rien. Le monde de la classification «fait de l’UML», mais UML est une représentation, pas une méthode. On cherchera en vain une démarche méthodologiquee par classification formalisant le processus permettant de passer de l’énoncé d’un problème à sa réalisation informatique, comme cela existe pour disposant de l'expérience, de la communauté d'utilisateurs et d'un marché d'outils concurrentiel du niveau de HOOD, Buhr ou SART par exemple.

    À faire... 

    Mettre à jour ceci (et la suite) avec UML, RUP

    Ceci peut s'expliquer également par l'histoire : le monde de la classification est parti d'une approche langage, et le souci a plus été de représenter les arborescences de classes (qui deviennent rapidement très compliquées) que de guider le processus de conception lui-même. Les méthodes ne sont arrivées que beaucoup plus tard; il s'agit alors généralement de méthodes propriétaires, liées à un seul fournisseur d'outils. Enfin, beaucoup de méthodes (elles bien établies) intègrent une dimension de classification, sans que celle-ci constitue la direction principale. C'est en particulier généralement le cas des méthodologies «entités-relations» qui sont présentées au paragraphe suivant.

    Méthodologies par entités-relations

    modifier

    Shlaer et Mellor

    modifier

    La méthode de Shlaer et Mellor [Shl88] est caractéristique des méthodologies entités-relations modernes, qui se considèrent «orientées objet» dans la mesure où les «entités» manipulées sont des abstractions d'objets du monde réel. Elle vise essentiellement les applications centrées sur les bases de données, car les entités sont considérées comme des «tables» et les relations comme des «jointures» (au sens de SQL). Certaines relations plus compliquées [m:n] peuvent elles-mêmes être représentées sous forme de tables. L'héritage est introduit par la relation «est-un». Ce modèle structurel est complété par des diagrammes en flots de données exprimant les traitements.

    C'est essentiellement une méthode d'analyse, dans la mesure où l'ouvrage de référence se concentre essentiellement sur la façon d'obtenir les renseignements du client afin d'obtenir une «bonne» modélisation du système à concevoir. En revanche, on ne parle que très peu de la dimension verticale : comment subdiviser un système complexe en sous-systèmes plus simples.

    OMT / Rumbaugh

    modifier

    La méthode OMT [Rum94] ressemble beaucoup par certains points à la méthode de Shlaer et Mellor, mais l'aspect «bases de données» n'y est pas fondamental : celles-ci ne sont considérées que comme une façon parmi d'autres d'implémenter les notions d'entités et d'attributs. L'idée de base est que tout système doit être décrit de trois façons orthogonales :

    • L'aspect structurel, décrit par un schéma d'entités-relations. Il comporte des notations spéciales pour les relations d'agrégation et d'héritage.
    • L'aspect événementiel, décrit par un schéma états-transitions. Il décrit les événements susceptibles de provoquer des traitements, et les différents états du système.
    • L'aspect fonctionnel, décrit par un diagramme de flots de données. Ce schéma décrit les traitements effectués sur les données.

    La méthode accorde la place principale à l'aspect structurel, les autres aspects n'intervenant que plus tard dans la conception. Comme avec Shlaer et Mellor, la description se veut complète, c'est-à-dire qu'elle veut exprimer tous les aspects de tous les composants du système. En conséquence, la décomposition verticale n'est que peu présente : le schéma d'entités-relations est découpé en «feuillets», mais qui constituent plus une facilité pour éviter de manipuler de trop grands diagrammes qu'une découpe hiérarchisée. L'analyse fonctionnelle demande d'identifier des sous-systèmes, mais ne dit rien des critères à utiliser pour ce faire. Il s'agit donc d'une méthode très complète au niveau spécification, mais on peut lui reprocher un manque de «profondeur» si on veut la poursuivre vers les étapes ultérieures du développement.

    Méthodologies : oui, mais...

    modifier

    Il n'est pas question de remettre en cause l'importance des méthodologies, mais ceci ne dispense pas de certaines précautions. Aucune méthodologie, pas plus qu'aucun langage[1] ne peut garantir le succès d'un développement; toute méthodologie a ses forces et ses faiblesses, et une méthodologie mal comprise ou mal utilisée peut même compliquer le processus de conception. C'est pourquoi nous pensons qu'il est utile de terminer cette partie par quelques mises en garde.

    Tout d'abord, être «orienté objet» est devenu une nécessité marketing à défaut d'être une nécessité technique; toutes les méthodes utilisent maintenant, de près ou de loin, ce terme. Il importe de bien voir ce que cela recouvre. En particulier, l'importance exagérée accordée souvent à l'héritage a conduit à rajouter une dimension d'héritage à beaucoup de méthodes où cela n'était pas indispensable.

    Ensuite, la méthodologie doit apporter une simplification par rapport au langage; il ne faut jamais oublier que le but premier est de lutter contre la complexité. Une méthode mal utilisée ou inadaptée au projet peut conduire à l'effet inverse. Il nous est ainsi arrivé de conduire des audits sur des projets où, après avoir peiné sur des pages et des pages de boîtes et de flèches, nous avons fini par demander à voir les spécifications Ada... qui exprimaient finalement beaucoup mieux la structure du projet que les diagrammes. En particulier, il est inutile de poursuivre l'application de la méthode au-delà du niveau sémantique du langage : à quoi bon tracer des arborescences de parcours et écrire du pseudo-code s'ils ne fournissent pas une vue plus synthétique que le programme Ada[2] lui-même ?

    Les méthodologies ont toujours été soumises à deux contraintes contradictoires : être limitatives pour fournir un cadre rigide, donc directif, aux développeurs, et être puissantes, donc permettre d'exprimer un maximum de choses. Le côté directif de la méthode est souvent mal perçu, et le programmeur est souvent tenté d’abandonner la méthode plutôt que de s’y plier. On peut résumer (un peu caricaturalement) ceci de la façon suivante :

    • La méthode : il est interdit de faire X.
    • Le programmeur : mais je veux faire X!
    • La méthode : désolé, mais c’est interdit
    • Le programmeur : dans ce cas, j’abandonne la méthode.

    L'histoire de la méthode HOOD par exemple est caractéristique : d'une approche strictement hiérarchisée en machines abstraites, on est passé progressivement à des «environnements» échappant à la hiérarchie, puis à des «types de données abstraits» qui échappent aux exigences des machines abstraites; et finalement, HOOD 4 a introduit une dimension de classification.

    Cette évolution élargit le champ d'application des méthodologies, en diminuant les contrôles qu'elles apportent. Si l'on décide d'adopter une méthodologie «puissante», donc vraisemblablement peu contraignante, il peut être souhaitable d'en limiter les possibilités pour améliorer les contrôles : on définira alors un «cadre d'utilisation» de la méthodologie, exprimant ces restrictions. Une liste soigneusement définie d’exceptions aux règles (et contrôlée par le responsable qualité) autorisera des violations dans certains cas au programmeur, et permettra d’éviter le syndrome ci-dessus.

    1. Fût-ce Ada (mais si, mais si...).
    2. Bien sûr, avec des langages de plus bas niveau qu'Ada, cette étape supplémentaire peut être nécessaire.

    Maquettage et développement progressif

    modifier

    Le développement progressif par maquettage ne constitue pas à proprement parler une méthode, tout au moins pas au sens des méthodes que nous avons vues dans les chapitres précédents. Il s'agit plutôt d'un contexte méthodologique compatible avec la plupart des méthodes, bien que plus particulièrement facile à mettre en œuvre avec les méthodes orientées objet.

    Maquettage et prototypage

    modifier

    Maquettage, prototypage, ces mots sont utilisés fréquemment en informatique, parfois de façon interchangeable, et généralement avec des significations mal définies. Rappelons donc leur définition dans le domaine général de l'industrie et voyons comment ils peuvent s'appliquer à l'informatique.

    Un prototype est le premier exemplaire d'un produit destiné à être produit par la suite en grande série. Il est parfaitement fonctionnel et permet d'évaluer complètement ses possibilités; en revanche, sa technique de fabrication est particulière, relevant souvent de méthodes artisanales. Son but est de tester les réactions des consommateurs avant de mettre en place la production en série, stade où il ne sera plus possible de revenir en arrière pour corriger d'éventuels petits défauts. La notion qui s'en rapproche le plus dans le domaine de l'informatique est ce que l'on appelle une version bêta : fourniture du logiciel à un petit nombre d'utilisateurs sélectionnés afin de tester leurs réactions, diagnostiquer les erreurs résiduelles par une utilisation en vraie grandeur, et corriger des imperfections notamment en matière d'ergonomie. Comme pour un prototype industriel, il n'est plus question à ce niveau de remettre en cause la structure fondamentale du logiciel.

    Toute autre est une maquette : il s'agit d'une version à petite échelle du produit, destinée à montrer l'apparence future de celui-ci et à vérifier auprès du client qu'il correspondra bien à sa demande. Son but est donc de vérifier la bonne orientation du développement, avant de poursuivre plus avant. La maquette est donc par nature incomplète, et ne vérifie pas une part importante du cahier des charges. Par exemple, un appareil électronique destiné à être embarqué à bord d'une fusée doit supporter des contraintes mécaniques et de vibrations draconiennes; cela n'empêche pas sa maquette de comporter des fils volants prêts à se détacher au moindre souffle! Dans le domaine du logiciel, une maquette permettra de vérifier l'interface utilisateur et les fonctionnalités fournies, mais pourra ne pas vérifier des contraintes du cahier des charges telles que le temps de réponse ou le volume de données manipulées.

    Ce qui différencie la maquette du prototype est donc que la maquette est par nature incomplète, et sert à valider une approche avant de poursuivre le développement. Au contraire, le prototype permet de vérifier qu'une première version complète du logiciel est satisfaisante, après développement, et permet seulement d'ajuster des défauts résiduels.

    Maquettage rapide

    modifier

    Il est bien connu que c'est lorsqu'un projet est terminé qu'il faudrait le commencer. Si l'on avait pu connaître dès le début toutes les difficultés qui ne sont apparues que plus tard, on aurait pu adopter une structure plus efficace. C'est pourquoi il est parfois intéressant de développer des maquettes préliminaires destinées à explorer le domaine de problème.

    La réalisation de ces maquettes est en général gouvernée par des contraintes opposées à celles du produit final : le temps de développement doit être très faible (pour ne pas trop grever le coût du développement final), il n'y aura aucune phase de maintenance, aucune contrainte de documentation ni de fiabilité ni de performance... On utilisera donc souvent des langages interprétés comme SmallTalk ou même APL.

    Bien entendu, il faut absolument jeter la maquette dès qu'elle a permis de faire avancer suffisamment la connaissance du domaine de problème. En effet, cette forme de développement présente un risque : les développeurs peuvent avoir le sentiment d'écrire deux fois le logiciel, et réagir en tentant de «faire grossir» l'implémentation maquette au lieu de le reconcevoir entièrement. Comme les impératifs de développement de la maquette sont différents de ceux du produit définitif (les compromis de départ correspondent à des contraintes radicalement différentes, parce qu'on ne se soucie pas de maintenance sur une maquette), la structure adoptée risque d'être inappropriée.

    Le programmeur hésite souvent à effacer de son disque dur ce qui représente de nombreuses heures de travail. Il faut le convaincre qu'il conservera le résultat le plus important : le savoir-faire acquis; l'effort de dactylographie supplémentaire n'est pas du travail perdu, car il a servi à augmenter sa connaissance du domaine de problème. D'ailleurs, bon nombre des meilleurs logiciels du marché ont été écrits entièrement deux fois (parfois plus).

    Maquettage progressif

    modifier

    Nous avons vu (paragraphe Surmonter la complexité) que les grandes étapes du développement, quelle que soit la méthode utilisée, étaient constituées de niveaux horizontaux successifs. Par définition, un tel niveau est trop complexe pour pouvoir être réalisé directement (ou alors, c'est que l'on est arrivé à la dernière étape de raffinement). En revanche, il est possible de réaliser une version simplifiée de chaque module : une telle maquette devra se présenter vis-à-vis de l'extérieur comme le module définitif (les spécifications sont établies), mais son implémentation peut être extrêmement sommaire. Par exemple, un module destiné à gérer des millions d'enregistrements dans une base de données répartie sur un réseau peut être maquetté par un module ne sachant gérer que dix enregistrements dans un tableau en mémoire... Il n'empêche que les fonctionnalités sont présentes et permettent de vérifier la complétude des spécifications.

    Nous parlerons de développement par maquettage progressif (à ne pas confondre avec le maquettage rapide) lorsque cette pratique est systématisée : à chaque étape du développement, on spécifie le module à concevoir, puis on en réalise une maquette exécutable : on dispose donc toujours, et dès les premières étapes, d'un programme complet, mais grossier. Au fur et à mesure du développement, les couches hautes sont remplacées par leur version définitive, mais implémentées au moyen de couches inférieures qui se trouvent elles-mêmes à l'état de maquettes, et ainsi de suite jusqu'à ce que l'ensemble du projet ait atteint l'état définitif. On pourrait donc également qualifier ces méthodes de «démaquettage progressif» : on évolue progressivement d'une version totalement «maquette» vers la version définitive. Ceci permet un passage en douceur de la spécification de haut niveau à l'implémentation, suivant l'idée de spécification progressive que l'on pratique avec SETL [Ros85].

    Un outil indispensable : le paquetage ADPT

    modifier

    Pour que l'approche par maquettage progressif soit intéressante, encore faut-il que le développement de la maquette n'entraîne qu'un effort négligeable par rapport au développement du module définitif. Le paquetage ADPT est l'outil indispensable pour spécifier des conceptions incomplètes, tout en conservant la possibilité de compiler en permanence l'état de la conception. Nous utiliserons ce paquetage dans nos exemples ultérieurs ; cette section n'est qu'une présentation générale de son fonctionnement.

    L'idée première de ce paquetage provient du composant du domaine public TBD_PACKAGE (TBD = To Be Defined, À définir), écrit par Bryce Bardin, et que l'on peut se procurer sur les serveurs du domaine public (Public Ada Library aux États-Unis, serveur du CNAM en France). ADPT signifie « À Définir Plus Tard ». L'idée de base est de fournir un moyen de spécifier... que certaines choses ne sont pas encore spécifiées. Les perfectionnements que nous avons apportés à ce paquetage par rapport à la version originelle concernent les possibilités de trace et de spécification de durée d'exécution, qui en font un véritable outil de maquettage, y compris en ce qui concerne les propriétés temporelles. Voici la structure générale de ce paquetage :

    package ADPT is
    -- Type complètement indéfini
    	type ADPT_Type is private;
    	function ADPT_Fonction (Info  : String   := ""; 
    	                       Durée : Duration := 0.0) 
    		return ADPT_Type;
    	ADPT_Valeur : constant ADPT_Type;
    -- Type Enumératif
    	type ADPT_Type_Enuméré is (ADPT_Valeur_Enumérée);
    	function ADPT_Fonction (Info  : String   := ""; 
    	                       Durée : Duration := 0.0)
    		return ADPT_Type_Enuméré;
    -- Type discret
    	type ADPT_Type_Discret is new ADPT_Type_Enuméré;
    	function ADPT_Fonction (Info  : String   := ""; 
    	                       Durée : Duration := 0.0)
    		return ADPT_Type_Discret;
    	ADPT_Valeur_Discrète : constant ADPT_Type_Discret;
    
    -- De même pour les type entiers, flottants, fixes,
    -- tableau, articles, accès, étiquetés...
    -- Autres déclarations
    	ADPT_Condition : constant BOOLEAN := False;
    	ADPT_Exception : exception;
    	procedure ADPT_Procédure (Info  : String   := ""; 
    	                         Durée : Duration := 0.0);
    	procedure ADPT_Actions  (Info  : String   := ""; 
    	                         Durée : Duration := 0.0);
    -- Ajustement du comportement
    	type ADPT_Comportement is (Ignorer, Tracer, Piéger);
    	ADPT_Comportement_Courant : ADPT_Comportement:= Ignorer;
    private
    	...
    end ADPT;
    

    Le paquetage complet (spécification et corps) est donné en annexe.

    Maquettage comportemental

    On fournit différents types ADPT, qui expriment la connaissance plus ou moins précise que l'on peut avoir du futur type. Chaque type possède une valeur, et une fonction fournissant une valeur du type. Par exemple, si l'on n'a besoin que d'un type et que l'on n'a encore aucune idée du choix futur d'implémentation, on le fournit sous la forme :

    type Mon_type is new ADPT_type;
    

    Comme le type ADPT_type est en fait un type privé, aucune opération n'est encore disponible. Plus tard, on peut décider que ce type doit être un type discret. On change alors la définition en :

    type Mon_type is new ADPT_type_discret;
    

    On peut l'utiliser comme n'importe quel type discret, par exemple comme indice de tableau. On décide ensuite d'utiliser un type entier, mais l'on ne souhaite pas encore se préoccuper du problème des bornes. On définit alors :

    type Mon_type is new ADPT_type_entier;
    

    Ceci nous permet d'utiliser les opérations arithmétiques. Finalement, on définit les bornes réelles et l'on écrit :

    type Mon_type is range 1..10;
    

    De cette façon, on peut faire évoluer le programme depuis une structure très grossière jusqu'à l'implémentation finale, en faisant effectuer à chaque étape par le compilateur des vérifications sémantiques correspondant au niveau des choix d'implémentation qui ont (ou n'ont pas été) faits. Lorsque l'on pense que tout est défini dans un module, il suffit de retirer la clause with ADPT; et de recompiler. Si l'on a oublié quelque chose d'indéfini, le compilateur se chargera de le signaler. De même, on peut demander au gestionnaire de bibliothèque Ada la liste des unités qui dépendent encore de ADPT; ceci procure une bonne idée de l'état d'avancement de la définition du projet. Lorsque cette liste est vide (et que l'on a fourni tous les corps nécessaires), on est prêt pour la première exécution en vraie grandeur !

    En plus des déclarations de types, le paquetage procure une exception (à utiliser lorsque l'on ne sait pas encore quelle exception lever dans une situation donnée), une constante booléenne appelée ADPT_condition qui permet d'écrire :

    if ADPT_Condition then...
    

    ainsi que deux procédures : ADPT_Procédure et ADPT_Actions. La première sert à prendre la place d'un appel de procédure lorsque la vraie procédure n'a pas encore été conçue. La seconde sert à repérer un endroit où le langage exige des instructions, mais où l'on n'a pas encore décidé quoi mettre. Ne jamais utiliser une instruction null pour cela! Il n'y aurait aucun moyen de vérifier que des actions indéfinies n'ont pas été oubliées par endroits.

    On peut vouloir essayer le programme avant qu'il ne soit complètement défini. Pour cela, le comportement des sous-programmes peut être changé en modifiant la variable (publique) ADPT_comportement. Lorsqu'elle est mise à Ignorer (la valeur par défaut), les appels aux sous-programmes ADPT n'ont aucun effet. Lorsqu'elle est mise à Tracer, le message que l'on peut fournir (facultativement) à chacun des appels est imprimé, fournissant ainsi une trace du parcours du programme. Lorsqu'elle est mise à Piéger, une exception est levée si un sous-programme est appelé. Cette exception est déclarée à l'intérieur du corps de paquetage, de façon à interdire sa récupération (sauf par un traite-exception when others bien sûr). Ce dernier comportement sert à identifier des appels oubliés à des sous-programmes ADPT.

    Maquettage temporel

    Toutes les opérations fournies possèdent un paramètre supplémentaire facultatif, Durée, représentant la durée du traitement effectué. La valeur par défaut est bien entendu 0. Ceci permet de simuler les temps d'exécution des procédures. L'idée est de permettre de maquetter également le budget temps.

    Lorsque l'on doit développer une application devant vérifier des contraintes temps réel, une façon de procéder consiste lors de la descente dans les niveaux d'abstraction à allouer à chaque procédure un «budget temps», un temps maximum à ne pas dépasser pour toute exécution. Lors de la phase d'analyse suivante, le budget temps est réparti entre les différentes opérations utilisées. Bien entendu, comme dans un vrai budget, on gardera une petite marge de sécurité à chaque répartition, et il sera possible de transférer du temps alloué depuis une procédure qui s'avère plus rapide que prévu vers une autre qui a du mal à tenir ses contraintes. Le paramètre Durée permet de maquetter ce budget temps : lorsque l'on implémente réellement une opération (c'est-à-dire que l'on passe d'une maquette à une implémentation réelle, mais qui utilise des maquettes de plus bas niveau), on répartit la durée qui était allouée à la maquette entre les durées de toutes les maquettes utilisées. Comme l'ensemble reste exécutable, il est possible de vérifier, notamment en fonction de diverses valeurs d'entrée, que le budget temps n'est jamais dépassé. Bien entendu, il faudra réajuster le budget si l'on découvre des dépassements! Mais ce n'est pas un moindre mérite de cette méthode que de permettre, dès les couches hautes de la conception, de vérifier au moins partiellement les propriétés temporelles du programme.

    Notons enfin que l'utilisation du paquetage ADPT est très nettement préférable à une solution où l'on se contenterait de mettre des PUT_LINE (et des delay) un peu partout pour réaliser des corps prototypes. Cette dernière solution peut être extrêmement dangereuse : comment être sûr que tous les modules ont atteint leur état définitif et que l'on n'a pas oublié des PUT_LINE (ou pire des delay!) quelque part? En utilisant ADPT en revanche, il suffit de demander au gestionnaire de programme la liste des modules qui en dépendent. S'il n'en existe plus, tout va bien. S'il en existe encore (mais que l'on pense qu'en fait tous les modules ont atteint leur état final), il suffit d'enlever les clauses with ADPT des modules concernés, et de recompiler. Si jamais il restait encore des appels aux fonctionnalités du paquetage, le compilateur aura l'extrême obligeance de vous les signaler...

    Ce paquetage ADPT est un outil extrêmement puissant, à toujours posséder dans sa bibliothèque Ada. Évidemment, il n'y a aucune raison de le standardiser (au sens ISO); on doit plutôt l'adapter en fonction de son goût personnel et de ses habitudes de programmation.

    Avantages et inconvénients du maquettage progressif

    modifier

    La notion de maquettage progressif constitue la mise en œuvre pratique de la notion de parcours horizontal du V de développement dont nous avions parlé en première partie (paragraphe Le parcours horizontal du V de développement). Son avantage principal réside dans l'absence de phase d'intégration finale : le projet est en permanence vérifié dans sa globalité. Ceci donne l'impression d'avancer toujours sur du terrain solide, puisqu'on ne fait pas un pas en avant sans avoir auparavant vérifié que cette progression était cohérente et s'intégrait aux besoins du reste du projet.

    Cette démarche favorise également le développement par équipes (relativement) indépendantes : on donne à un groupe la réalisation effective d'un composant dont on lui fournit la maquette. Celle-ci constitue donc une définition opérationnelle (mais réduite) de la fonctionnalité à réaliser. Elle constitue le contrat de réalisation, et il est toujours possible de comparer le comportement du module complet à celui défini par la maquette. On peut également définir (et mettre au point) les programmes de tests unitaires sur la maquette, avant d'aborder la réalisation complète; la validation du module définitif consistera à exécuter correctement ces tests unitaires qui sont connus avant le début de la réalisation. On voit que dans ces conditions, il est aisé de conduire un développement indépendant, en ignorant le contexte global dans lequel le composant devra être intégré.

    De plus, une fois définies les maquettes, chaque partie de l'équipe de développement peut s'occuper de sa partie en utilisant les corps maquettes des autres modules en cours de développement dont elle a besoin; on évite ainsi les goulots d'étranglement qui se produisent lorsqu'une partie du projet dépend de la disponibilité d'un module particulier.

    Mais le développement progressif pose également quelques difficultés. Le plus gros problème est celui du suivi. Dans un développement en «chute d'eau» classique, il est aisé d'établir des plannings de développement, avec des points précis (appelés jalons) à atteindre à des dates données. Souvent, ces points sont vérifiés et ont des conséquences contractuelles et financières : on trouvera souvent dans des contrats de développement des clauses comme :

    Le JJ/MM/AA, le fournisseur remettra au client les spécifications détaillées; celui-ci disposera d'un délai d'un mois pour les accepter ou demander des modifications. Après acceptation par le client, une somme correspondant à 25% du montant du contrat sera payée.

    Avec la notion de parcours horizontal du V de développement, il n'est plus possible de définir de telles étapes. Tout au plus pourrait-on parler de pourcentage du projet implémenté «de façon définitive», par opposition aux éléments encore à l'état de maquette. Mais la définition du pourcentage et l'évaluation de l'état de la réalisation seraient fortement subjectives, et trop difficiles à définir pour permettre d'avoir des implications financières, comme le paiement d'avances sur contrat.

    Indépendamment même des considérations financières, il est facile de répondre à un responsable qui s'enquiert de l'état de développement d'un projet : «Nous avons fini la conception générale, et nous sommes à environ 30% des spécifications détaillées.» Le responsable pourra se faire une idée de l'état d'avancement du projet[1]. Il est plus difficile de répondre : «Nous avons implémenté le troisième niveau d'abstraction, et nous travaillons à l'implémentation du quatrième avec certaines maquettes de cinquième niveau opérationnelles.»

    Une échappatoire consiste à définir une équivalence entre le développement de certains plans d'abstraction et les phases habituelles du développement, en particulier lorsque l'on dispose d'une méthode bien formalisée. On peut alors spécifier par contrat (en utilisant les critères de HOOD) :

    La fourniture complète de toutes les rubriques H1 et H2 de la conception pour les objets de premier et deuxième niveaux constituera la conception générale, et donnera lieu au paiement de l'avance après acceptation par le client.

    Mais ceci ne constitue qu'une tentative de «raccrocher» le processus de développement progressif aux éléments classiques de la chute d'eau, dont on cherche au contraire à se séparer. On voit donc que le maquettage implique une remise en cause complète de tout le processus de gestion de projet, et l'on ne dispose pas encore de cadre bien admis permettant d'assurer ce suivi.

    Cette difficulté à définir l'état d'avancement d'un projet par rapport à la réalisation finale peut paraître anecdotique, pour ne pas dire ridicule, aux développeurs individuels ou à ceux qui n'ont pas eu à développer des projets en milieu industriel; elle est en fait suffisamment forte pour interdire totalement le développement progressif dans certains contextes.

    1. Idée souvent considérablement faussée par rapport à l'état réel du projet, surtout si la mise au point se révèle difficile, ou que des difficultés apparaissent lors de l'intégration. Mais ceci est une autre histoire...

    Exercices

    modifier
    1. Analyser en composition le système de contrôle d'une centrale nucléaire : surveillance de la température des réacteurs, et déclenchement d'alarmes en cas de valeurs hors normes. On se limitera au premier niveau d'abstraction (!), mais l'on fournira une maquette opérationnelle.
    2. Écrire une maquette opérationnelle des fonctionnalités de haut niveau d'un système de gestion de bases de données, mais dont l'implémentation ne gère que quelques données dans un tableau.
    3. Expliquer pourquoi le maquettage progressif, au contraire du maquettage rapide, doit s'effectuer dans le langage de l'implémentation définitive.