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

La réutilisation est un élément principal de l'abaissement des coûts de développements logiciels; il n'est plus acceptable de nos jours de récrire éternellement les mêmes morceaux de code. L'électronicien qui souhaite construire un circuit logique ne reconçoit pas à chaque fois les portes dont il a besoin: il achète des composants qui lui fournissent les fonctions logiques nécessaires. C'est d'ailleurs grâce à cette notion de composant réutilisable, à faible coût car produit à un grand nombre d'exemplaires, que l'industrie électronique a pu prendre le développement que nous lui connaissons aujourd'hui. Or il faut bien reconnaître que cette démarche est actuellement quasi inexistante dans le monde du logiciel. Combien de programmes de tri sont-ils recodés chaque année ? Il est temps que l'industrie du logiciel redécouvre les méthodes qui ont fait le succès de bien d'autres branches industrielles... avec quelques dizaines d'années de retard !

Composants logiciels

Il faut donc apprendre à développer en produisant et en utilisant des composants logiciels réutilisables. Mais là non plus, la bonne volonté n'est pas suffisante: il existe des techniques et des méthodes nécessaires à la bonne mise en place d'une politique de réutilisation dans l'entreprise.

Cette troisième partie va présenter ces techniques et méthodes pour le développement de composants logiciels réutilisables, et nous verrons qu'encore une fois, Ada fournit l'outil qui supporte activement ces méthodes.

En guise d'introduction...

modifier

Écrire un bon composant logiciel est loin d'être trivial. Avant de discuter de toutes les contraintes que cela impose, nous prendrons un exemple (un peu caricatural, peut-être, encore que...) et suivre les péripéties qui nous mènent de la vue naïve d'une fonctionnalité souhaitée à un réel composant logiciel.

Nous voulons réaliser une fonction Permute qui permute le début et la fin d'une chaîne de caractères, c'est-à-dire qui nous permette d'obtenir la chaîne de caractères « XYZABCD » à partir de la chaîne « ABCDXYZ ». Nous exprimons cette spécification comme :

procedure Permute (La_Chaîne : in out String;
                   Coupure   : in     Integer);

Nous donnons le corps à écrire à un stagiaire[1] qui a une certaine expérience d'autres langages de programmation, mais pas tellement d'Ada et qui écrit :

procedure Permute (La_Chaîne : in out String;
                   Coupure   : in     Integer) is
	Pos_Char : Integer := 1;
begin
	for I in Coupure+1..La_Chaîne'Length loop
		La_Chaîne (Pos_Char) := La_Chaîne (I);
		Pos_Char := Pos_Char + 1;
	end loop;
	for I in 1..Coupure loop
		La_Chaîne (Pos_Char) := La_Chaîne (I);
		Pos_Char := Pos_Char + 1;
	end loop;
end Permute;

Dès le premier essai, il s'aperçoit que ceci ne peut pas fonctionner, car le programme de test :

with Ada.Text_IO; use Ada.Text_IO;
with Permute;
procedure Test is
	S : String(1..7) := "ABCDXYZ";
begin
	Permute (S, 4);
	Put_Line (S);
end Test;

imprime « XYZDXYZ » ! En effet on va écraser le début de la chaîne alors que l'on en a encore besoin. Le stagiaire en déduit que la chaîne de sortie doit être différente de la chaîne d'entrée, et récrit la procédure comme suit :

procedure Permute (Entrée  : in  String;
                   Sortie  : out String;
                   Coupure : in  Integer) is
	Pos_Char : Integer := 1;
begin
	for I in Coupure+1..Entrée'Length loop
		Sortie (Pos_Char) := Entrée (I);
		Pos_Char := Pos_Char + 1;
	end loop;
	for I in 1..Coupure loop
		Sortie (Pos_Char) := Entrée (I);
		Pos_Char := Pos_Char + 1;
	end loop;
end Permute;

Le stagiaire procède alors à quelques tests et vient annoncer fièrement que « ça marche ». Que peut-on dire alors de cette solution ?

  • La spécification en a été changée à cause d'un problème d'implémentation.
  • Le composant ne fonctionne que pour les chaînes dont la borne inférieure d'index est 1.
  • Il ne fonctionne pas correctement si les variables Entrée et Sortie n'ont pas la même taille.
  • Il risque de ne pas fonctionner correctement si la variable associée au paramètre Sortie est la même que celle associée au paramètre Entrée.
  • La signification exacte de la variable Coupure n'a jamais été spécifiée. Désigne-t-elle le dernier caractère de la première chaîne ou le premier de la deuxième ?
  • Le comportement de la procédure lorsque Coupure n'appartient pas à l'intervalle des bornes de la chaîne à couper n'a jamais été spécifié.

Le premier point est extrêmement grave : la première tâche de celui qui écrit un composant est de réaliser le composant demandé, pas un autre qui l'arrange mieux ! Ceci dit, une spécification n'est pas sacro-sainte non plus, et il se peut que l'implémenteur y découvre une faiblesse ; il doit alors proposer une nouvelle spécification, et n'implémenter qu'après accord du client. Ici, la spécification initiale ne prévoit qu'une permutation de la chaîne sur elle-même ; cela oblige à utiliser une variable pour l'appel, même si la chaîne intervient en fait dans une expression chaîne plus vaste.

Le deuxième point est une erreur classique des gens qui ne sont pas habitués à Ada : rien n'impose qu'un tableau soit indexé à partir de 1, et ce ne sera en général pas le cas si l'on appelle le composant en donnant comme paramètres réels des tranches de tableau. Une bonne utilisation des attributs 'First et 'Last permet de remédier aisément à ce problème.

Ada permet de prendre des tranches de tableaux (unidimensionnels), qui sont elles-mêmes des tableaux. Ainsi, S(5..10) représente la tranche du tableau S commençant au 5ème élément et se terminant au 10ème ; la borne inférieure de ce tableau est alors 5, et sa borne supérieure 10. Une tranche de variable est une variable, et peut donc figurer en partie gauche d'une affectation : S(5..10):= (others => 0);remet les éléments 5 à 10 du tableau S à 0.

Les points 3 et 4 ne semblent pas dramatiques a priori : il semblerait qu'il suffise de prévenir l'utilisateur du sous-programme que les deux variables doivent être différentes et de même longueur. Il serait possible de vérifier l'égalité des longueurs et de lever une exception si ce n'était pas le cas ; en revanche, il n'existe aucun moyen de s'assurer que les variables fournies sont différentes. Pire : le composant peut fonctionner parfaitement, même avec deux fois la même variable, sur une implémentation qui passe les paramètres par copie, et donner des résultats faux si les paramètres sont passés par adresse. Le bon fonctionnement du composant repose donc sur des éléments extérieurs et incontrôlables.

Les deux derniers points proviennent clairement d'un manque de spécification au départ. Cela ne signifie pas que la solution soit mauvaise en soi ; ce que nous voulons dire, c'est que le comportement doit être exactement spécifié a priori, et non résulter des hasards de l'implémentation. Nous voyons hélas trop souvent l'application de la définition suivante :

Spécification : description du comportement constaté d'une implémentation !

Nous décidons donc que Coupure désigne le dernier caractère de la première chaîne, et que s'il n'appartient pas à l'intervalle des bornes, la chaîne fournie doit être identique à la chaîne en entrée. Une deuxième itération du problème conduit à améliorer la spécification ainsi :

function Permute (La_Chaîne : String;
		          Coupure   : Integer) return String;

De plus, nous expliquons au stagiaire la possibilité de déclarer des variables locales dont la taille dépend des paramètres d'entrée. Cette fois-ci, il produit l'implémentation suivante :

function Permute (La_Chaîne : String;
		          Coupure   : Integer) return String is
	Résultat : String (La_Chaîne'Range);
	Pos_Char : Integer := Résultat'First;
begin
	if Coupure not in La_Chaîne'Range then
		return La_Chaîne;
	end if;
	for I in Coupure+1..La_Chaîne'Last loop
		Résultat (Pos_Char) := La_Chaîne (I);
		Pos_Char := Pos_Char + 1;
	end loop;
	for I in La_Chaîne'First .. Coupure loop
		Résultat (Pos_Char) := La_Chaîne (I);
		Pos_Char := Pos_Char + 1;
	end loop;
	return Résultat;
end Permute;

Cette version paraît nettement plus satisfaisante : le cas où Coupure n'appartient pas à l'intervalle est traité conformément aux spécifications, et il n'y a plus de dépendance à une quelconque valeur des bornes. Pourtant, il y a encore un cas de figure qui ne fonctionne pas correctement. Si nous écrivons :

	S : String(Integer'Last-1 .. Integer'Last) := "AB";
begin
	S := Permute (S, S'First);
end;

la fonction Permute lèvera l'exception Constraint_Error ! En effet, l'instruction

Pos_Char := Pos_Char + 1;

sera effectuée pour une valeur de Pos_Char déjà égale à Integer'Last, et lèvera donc l'exception. Il faut faire un cas particulier si la borne supérieure de La_Chaîne est Integer'Last. Et comme l'expression Coupure+1 apparaît également, il faut aussi faire un cas particulier si Coupure prend la valeur Integer'Last... Voilà un composant qui commence à devenir bien compliqué, juste pour un cas qui a toutes chances de ne jamais se produire en pratique ! La tentation est forte de laisser tomber, au besoin de documenter « qu'il ne faut pas appeler la fonction avec une chaîne dont la borne supérieure est Integer'Last »... C'est ainsi que des logiciels qui fonctionnent depuis des années deviennent parfois fous sur des cas particuliers.

La nécessité de fiabilité conduit donc à des complications supplémentaires. Ceci est fréquent lorsque l'on a écrit un composant sans tenir compte des cas limites, et que l'on essaie ensuite de le faire fonctionner même pour ces cas-là. Mais avons-nous utilisé toutes les possibilités du langage ? N'y aurait-il pas une autre implémentation qui traiterait tous les cas de façon égale ? Bien entendu, la réponse est oui[2]. Les tranches de tableaux constituent un outil remarquablement puissant, car leur sémantique dans les cas limites est très bien définie... ce qui veut dire que c'est le compilateur qui s'embête à notre place. Nous pouvons récrire le corps de notre fonction comme ceci :

function Permute (La_Chaîne : String;
			      Coupure   : Integer) return String is
begin
	if Coupure not in La_Chaîne'Range then
		return La_Chaîne;
	else
		return	La_Chaîne (Coupure+1..La_Chaîne'Last) &
					La_Chaîne (La_Chaîne'First .. Coupure);
	end if;
end Permute;

Cet énoncé traite correctement tous les cas de figure normaux, y compris celui où Coupure vaut La_Chaîne'Last, grâce à la sémantique d'Ada qui autorise les chaînes vides. Il est plus proche de la spécification, et il est même plus efficace : en spécifiant des opérations de tranches de tableau, on permet au compilateur d'utiliser des opérations de déplacement de chaînes d'octets, ce qu'il n'aurait pu faire quand on décrit le transfert caractère par caractère. Est-il parfait ? Non, car il existe encore un cas où il lèvera Constraint_Error : si La_Chaîne'Last = Coupure = Integer'Last ! Car alors, le calcul de Coupure+1 débordera. Mais l'on peut remarquer que dans ce cas (ainsi que dans tous ceux où Coupure = La_Chaîne'Last), la chaîne à renvoyer est précisément la chaîne d'origine. On peut donc écrire :

function Permute (La_Chaîne : String;
			      Coupure   : Integer) return String is
begin
	if Coupure not in La_Chaîne'First .. La_Chaîne'Last-1
then
		return La_Chaîne;
	else
		return La_Chaîne (Coupure+1..La_Chaîne'Last) &
				  La_Chaîne (La_Chaîne'First .. Coupure);
	end if;
end Permute;

Sommes-nous au bout de nos peines ? Non ! Il existe encore un cas de figure qui lève Constraint_Error. Le lecteur est prié de réfléchir avant de lire la suite... L'utilisateur a le droit de déclarer :

S : String(1..Integer'First);

Il s'agit bien sûr d'une chaîne vide, mais sa borne supérieure est effectivement Integer'First. Si notre fonction est appelée sur un tel monstre, l'expression La_Chaîne'Last-1 lèvera Constraint_Error. Faut-il introduire une complication supplémentaire pour tester ce cas qui relève clairement de la pathologie ? Mais après tout, faut-il vraiment tester les autres cas ? Puisque le compilateur fait les tests pour nous, autant l'utiliser ! Nous pouvons écrire :

function Permute (La_Chaîne : String;
			      Coupure   : Integer) return String is
begin
	return La_Chaîne(Coupure+1..La_Chaîne'Last) &
	       La_Chaîne (La_Chaîne'First .. Coupure);
exception
	when Constraint_Error =>
		return La_Chaîne;
end Permute;

Cette version, qui est satisfaisante du point de vue de la sécurité, présente encore un défaut : la borne inférieure de la chaîne renvoyée est Coupure+1. La chaîne subit donc un «glissement» vers la droite. Il y a peu de chances que l'utilisateur le remarque, sauf s'il utilise la fonction dans un contexte qui requiert une borne inférieure précise. En fait, nous n'avons pas spécifié la valeur de la borne inférieure de la valeur retournée ; les deux comportements possibles qui viennent à l'esprit sont soit de prendre la même valeur que la chaîne d'origine, soit de forcer la borne inférieure à 1. Mais quelle que soit la solution adoptée, il faut que l'invariant soit garanti. Or notre solution actuelle, non seulement ne correspond à aucune de ces solutions, mais de plus n'a pas une sémantique uniforme puisque dans les cas où l'on passe par le traite-exception, la borne inférieure n'est pas modifiée. Décidons de toujours renvoyer en sortie les mêmes bornes qu'en entrée (cela paraît logique), et utilisons une conversion de sous-type pour remettre les bornes désirées :

function Permute (La_Chaîne : String;
			      Coupure   : Integer) return String is
	subtype Ajustement is 
		String (La_Chaîne'First .. 
		        La_Chaîne'First + La_Chaîne'Last-Coupure-1);
begin
	return Ajustement(La_Chaîne(Coupure+1..La_Chaîne'Last))
	      & La_Chaîne (La_Chaîne'First .. Coupure);
exception
	when Constraint_Error => return La_Chaîne;
end Permute;

On peut encore discuter de cette spécification. Il semble inutile d'autoriser des valeurs négatives pour Coupure ; pourquoi alors ne pas lui donner le sous-type Natural au lieu de Integer ? Certes, mais des valeurs négatives peuvent résulter de calculs intermédiaires, et alors il ne faudrait pas non plus autoriser de valeurs supérieures à La_Chaîne'Last+1. Pour être cohérent, il faut donc soit autoriser n'importe quelle valeur pour Coupure (exemple précédent), soit n'autoriser qu'un débordement d'une seule valeur (et lever Constraint_Error sinon). Nous en resterons donc à la version précédente qui a le mérite de l'uniformité et d'une sémantique claire des cas limites.

Figure 26 : Performances des différentes versions de Permute
Test n° Compilateur 1, sans optimisation Compilateur 1, avec optimisation Compilateur 2, sans optimisation Compilateur 2, avec optimisation
1 (procédure) 2,01 2,14 0,92 0,68
2 (fonction) 3,70 3,28 5,23 1,74
3 (utilise "&", test) 3,55 3,12 4,06 1,40
4 (meilleur test) 3,55 3,12 4,12 1,43
5 (exception) 3,68 3,24 4,06 1,48
6 (ajustement des bornes) 3,97 3,46 4,73 1,43
Figure 26 : Performances des différentes versions de Permute

Le souci d'obtenir un composant tellement protégé ne va-t-il pas avoir des conséquences funestes sur les performances ? Nous avons effectué quelques mesures rapides sur les différentes versions présentées ici, avec deux compilateurs (un bon marché et un plus coûteux), avec et sans optimisation. Les temps d'exécution sont consignés dans le tableau de la figure 26.

Il apparaît clairement que le passage d'une procédure à une fonction retournant un type non contraint induit un surcoût ; mais ceci correspond également à un accroissement important de l'utilisabilité du composant. En revanche, à condition de compiler avec les optimisations, l'augmentation de sécurité des dernières versions n'induit aucun surcoût mesurable en termes d'efficacité. Ceci signifie que le surcroît de précision apporté dans la définition a été mis à profit par le compilateur pour optimiser le code généré. Il n'y a donc pas de raison de supposer a priori qu'une version sécurisée d'un composant doive être nécessairement moins efficace.

Que peut-on déduire de cet exemple ?

  • Qu'il y a un abîme entre un composant qui marche «presque» toujours et un composant qui marche toujours.
  • Qu'une bonne connaissance du langage permet de trouver des solutions plus simples, plus élégantes, plus lisibles et plus efficaces.
  • Qu'une fois que l'on a trouvé la bonne solution, elle paraîtra évidente à ceux qui la reliront. Ce qui ne signifie pas qu'elle était simple à trouver la première fois.
  • Qu'il faut un état d'esprit extrêmement rigoureux pour envisager tous les cas limites et définir une sémantique acceptable dans tous les cas.
  • Qu'il ne faut pas avoir d'a priori sur les constructions supposées être plus (ou moins) efficaces que d'autres tant qu'on n'a pas eu l'occasion d'effectuer des mesures.
  • ... et qu'il ne faut pas confier l'écriture de composants logiciels à des stagiaires.
  1. L'écriture de ce genre de petites fonctions est souvent utilisée pour occuper les stagiaires et les éloigner des points critiques du projet...
  2. Sinon, nous n'aurions pas choisi cet exemple...

Qu'est-ce qu'un composant logiciel ?

modifier

Définition

modifier

Qu'appelle-t-on composant logiciel ? Ce terme est né de l'analogie avec les composants matériels. Mais qu'est-ce qu'un composant matériel ? Dans le domaine voisin de l'électronique, nous trouvons des composants de base: résistances, condensateurs, transistors... On trouve aussi des composants de plus haut niveau : alimentations électriques, amplificateurs. Certains appareils (y compris des ordinateurs !) sont même des produits autonomes qui sont utilisés comme composants dans des systèmes de taille supérieure. Qu'y a-t-il de commun entre ces différentes sortes de composants qui justifie l'emploi d'un même terme et qui soit susceptible de s'appliquer aux composants logiciels ?

La première caractéristique est que ces éléments n'ont pas a priori d'utilité en eux-mêmes ; ils ne servent qu'à la réalisation d'ensembles plus vastes, résultant de la composition de ces éléments.

Une deuxième caractéristique est que le même composant peut être utilisé pour réaliser des systèmes très différents, n'ayant aucun rapport entre eux. Ce sont les mêmes résistances électroniques qui servent pour un lecteur de cassette, le contrôleur d'un four à micro-ondes ou le guidage d'un missile.

Une troisième caractéristique, conséquence des précédentes, est que le concepteur d'un composant ne sait pas dans quel contexte celui-ci sera utilisé. Il ne peut donc s'appuyer sur aucun élément extérieur à la nature même de son composant pour guider sa conception[1]. En revanche, c'est l'utilisateur du composant qui devra subir les contraintes propres à ce dernier. Ainsi par exemple, le fabricant d'un ventilateur prévoira quatre trous pour le fixer, également répartis sur la circonférence. Pourquoi également répartis? Parce qu'en l'absence de connaissance sur les contraintes de fixation de son composant, il a dû prendre une décision arbitraire. Si la position des trous ne satisfait pas l'utilisateur, ce sera à ce dernier de mettre des cales, des équerres, etc., pour pouvoir utiliser le composant. Noter que l'utilisateur aurait pu avoir la réaction de dire qu'en l'absence d'un ventilateur répondant exactement à ses spécifications, il en faisait réaliser un sur mesure. Cela fait longtemps que l'on sait dans le domaine du matériel qu'il est plus économique d'adapter ses exigences aux composants existants.

Parfois, un composant peut être conçu dans un but spécifique, mais généralisé bien au-delà de son but initial. C'est ainsi que les microprocesseurs furent initialement développés pour les besoins de la conquête spatiale... domaine qui ne représente plus qu'une minuscule partie de leur marché actuel.

Enfin, un composant doit être standardisé, ou tout au moins suffisamment stable pour rester compatible dans le temps. La standardisation des interfaces, en permettant l'évolution technologique sans remise en cause des utilisations, en favorisant l'apparition d'un marché concurrentiel et en entraînant la standardisation des éléments annexes[2], est pour beaucoup dans l'essor de l'industrie électronique.

À partir de ces exemples, nous définirons donc un composant logiciel comme une entité logicielle réutilisable, définissable indépendamment de son contexte d'utilisation, et dont l'interface est stable et figée.

  1. Cette phrase faisait partie de la première version de cet ouvrage, publié quelques mois avant l’accident d’Ariane 501, dû précisément au non-respect de ce bon principe.
  2. C'est parce que l'espacement des pattes des circuits est standardisé à 1/10" que les supports de circuits ont pu être standardisés et se développer.

Contraintes supplémentaires pour les composants logiciels

modifier

Cette définition implique des contraintes supplémentaires pour un composant logiciel par rapport à un composant qui fait partie d'un projet donné.

Pour être réutilisable, le composant doit être général, c'est-à-dire correspondre à un besoin partagé par de nombreux développements. Ceci nécessite un effort d'abstraction supplémentaire au niveau de la conception, afin de rendre le composant indépendant de toute utilisation particulière. Il doit également être complet, mais non surabondant : il doit offrir toutes les fonctionnalités indispensables, et seulement celles-là. De plus, il doit être fortement cohérent : il ne doit pas être possible de le couper en deux tout en maintenant un faible couplage entre les deux parties. Une autre façon de formuler cette exigence est de dire qu'un composant doit traiter entièrement un seul aspect d'un problème. Ceci ne s'obtient pas en général du premier coup, et c'est au fil des réutilisations que le composant «décante» pour arriver à un juste équilibre dans les fonctionnalités offertes.

Le principe de l'indépendance du module implique que sa sémantique doit être autonome et définie. La connaissance d'une documentation minimale doit être suffisante pour l'utiliser. L'utilisateur doit pouvoir avoir vis-à-vis du composant une attitude de « tu te débrouilles, je ne veux pas le savoir ».

Enfin le composant doit être robuste, notamment pour les cas limites. Un composant spécifique ne sera pratiquement jamais utilisé dans toute l'étendue de ses capacités, ce qui peut d'ailleurs permettre à des erreurs de rester cachées pendant longtemps, parfois même éternellement. Un composant réutilisé de nombreuses fois a toutes les chances de subir tous les cas de figure possibles, et doit donc pouvoir faire face à toutes les circonstances.

Terminons par une remarque: dans tout projet, il existe des parties nécessairement spécifiques, notamment celles qui sont bâties sur les composants logiciels et les font «jouer» pour obtenir le résultat escompté. Il existe donc forcément des composants qui ne correspondent pas aux critères ci-dessus, et ce serait une erreur d'imposer à un projet de tout faire sous forme réutilisable.

Coût de développement des composants

modifier

Compte tenu des contraintes que nous avons exposées, il n'est pas étonnant que le coût de développement d'un composant logiciel réutilisable soit supérieur à celui d'un composant spécifique. Le composant spécifique a en effet le droit de connaître (plus ou moins) par qui et comment il a été appelé; il peut faire des hypothèses simplificatrices sur ses paramètres et il n'est pas toujours nécessaire de le rendre absolument fiable, face aux cas limites notamment.

On estime empiriquement que le développement d'un composant réutilisable coûte environ deux à trois fois plus cher que son équivalent spécifique. [Fav91] a publié une étude où il présente des mesures tenant compte non seulement du surcoût pour «écrire réutilisable», mais également du surcoût pour utiliser des composants (y compris la phase d'apprentissage); sur différents composants, le nombre de réutilisations pour amortir le développement varie de 1,33 à près de 13 dans un cas particulier! Pour la plupart des exemples, le seuil de rentabilité est inférieur à 4 réutilisations. Cette constatation fondamentale a des conséquences très importantes:

  • Le surcoût n'est pas énorme, car il sera amorti au bout de quelques réutilisations du composant logiciel, une situation que nous envient ceux qui fabriquent des composants matériels !
  • Le surcoût est cependant suffisamment important pour ne pouvoir être justifié au niveau d'un projet individuel. Un chef de projet qui prendrait sur lui d'exiger de ses équipes de développement qu'elles définissent systématiquement les modules sous forme de composants réutilisables serait assuré de ne pas tenir ses budgets.
  • Une politique de réutilisation ne peut donc être rentable que si elle est établie à un échelon supérieur à celui du projet individuel : c'est au niveau de l'entreprise qu'une telle politique doit être mise en place, afin de permettre le partage des composants entre plusieurs projets. Il est donc nécessaire de mettre en place une structure transversale, coiffant les projets, pour la réutilisation. Des investissements supplémentaires, non affectables directement à un projet, doivent être consacrés à la réutilisation. Nous développerons ce point par la suite.

Exercices

modifier
  1. Écrire la spécification d'un composant d'interface graphique de base (définissant les coordonnées d'écran et des fonctions élémentaires, comme d'allumer un pixel dans une certaine couleur). Le composant doit être indépendant de la résolution et du nombre de couleurs disponibles à l'écran.
  2. Spécifier complètement un composant permettant de traiter des dates et d'effectuer des calculs entre dates sur toute la période historique (de -3000 à +2000). Traiter soigneusement les cas exceptionnels, en particulier le passage du calendrier julien au calendrier grégorien.
  3. Prendre plusieurs projets (comme des projets de fin d'étude d'élèves précédents) et analyser les parties communes qui auraient dû faire appel à des composants réutilisables.

Organisation et classifications des composants logiciels

modifier

Il existe plusieurs façons de classer les composants logiciels. Nous avons déjà vu que les paquetages pouvaient être subdivisés en collections de données, collections de sous-programmes, machines abstraites, types de données abstraits et gestionnaires de données. Nous allons présenter maintenant d'autres critères d'organisation. Ces différentes classifications sont orthogonales: deux composants appartenant à la même classe selon un critère pourront parfaitement appartenir à des classes différentes selon un autre.

L'intérêt des classifications est double: d'abord, elles servent de base à la documentation des composants et aux moyens de recherche d'un composant répondant à un comportement souhaité; ensuite, elles permettent une normalisation des espèces de composants. Un composant qui apparaîtrait «hors norme», inclassifiable, aurait toutes les chances de souffrir d'erreurs de conception, et d'être un mauvais candidat à la réutilisation.

Taxonomie comportementale

modifier

Booch a défini dans son second livre [Boo87] une taxonomie des composants fondée sur les propriétés de leur comportement. Cette taxonomie a depuis été reprise et complétée par Berard [Ber87].

Comportement parallèle

modifier

Le premier critère de classification se fonde sur le comportement du composant en cas d'utilisation par plusieurs tâches en parallèle. Le composant peut être:

  • Séquentiel. Le bon fonctionnement du composant n'est garanti qu'en cas d'utilisation par une seule tâche.
  • Gardé. L'objet fournit un moyen d'exclusivité d'accès (sémaphore), mais n'en vérifie pas le bon usage. Le respect du protocole en cas d'utilisation par plusieurs tâches est à la charge du client.
  • Parallèle (ou protégé selon Berard). L'objet assure lui-même l'exclusivité d'accès en sérialisant des demandes concurrentes. Il ne peut y avoir qu'une seule tâche à la fois dans le composant.
  • Multiple. Le composant gère une politique de type «lecteurs-écrivains» pour assurer un comportement correct et un parallélisme maximal en cas d'accès multiple.

À la première lecture, on peut se demander pourquoi tous les composants n'appartiennent pas à la catégorie «multiple». La raison en est que le gain en fiabilité se paie par l'efficacité. Un composant multiple est totalement inutile pour un programme purement séquentiel...

Nous déconseillerons fortement l'écriture de composants gardés, puisqu'ils ne peuvent assurer eux-mêmes leur propre sémantique: leur bon fonctionnement dépend du respect du protocole par l'utilisateur. Leur seul avantage est de permettre d'enchaîner plusieurs opérations sans relâcher le sémaphore. Un tel comportement est cependant parfois nécessaire pour fournir des fonctionnalités de base, uniquement destinées à écrire des fonctionnalités de plus haut niveau qui seront, elles, protégées ou multiples.

On obtient assez facilement un composant parallèle à partir d'un composant séquentiel en rajoutant un paquetage comportant une tâche ou un objet protégé assurant la sérialisation des accès. Par conséquent nous considérerons que les deux comportements fondamentaux (et devant faire l'objet de codages distincts) sont le séquentiel et le multiple.

Berard a apporté une précision supplémentaire, en divisant chacun des comportements parallèles en deux, selon que la protection s'effectue au niveau des opérations ou au niveau des objets. On distingue ainsi le cas des composants parallèles au niveau des opérations, où deux tâches effectuant des opérations sur des objets différents sont sérialisées, et les composants parallèles au niveau des objets, où la sérialisation n'intervient que si les deux tâches effectuent des opérations sur le même objet.

Noter qu'Ada est par définition un langage réentrant. Dans le cas d'un type de donnée abstrait, un comportement séquentiel autorise tout de même plusieurs tâches à travailler simultanément sur des objets distincts. Ce n'est exclu que s'il existe une information globale commune à deux objets distincts, et donc que ceux-ci ne sont pas de «purs» types de données abstraits.

Contenance

modifier

Le deuxième critère de classification se fonde sur la capacité du composant de manipuler un nombre quelconque d'entités. Ce critère n'est en principe significatif que pour les gestionnaires de données. Le composant peut être:

  • Limité. La taille (ou la contenance) de l'objet est fixée pour toute sa durée de vie.
  • Non limité. La taille (ou la contenance) de l'objet peut varier au cours de sa vie, et n'est limitée que par la mémoire disponible sur la machine.
  • Borné (Berard). Le composant est doté d'une taille maximale lors de sa création. La taille utile est variable, mais ne peut envahir toute la mémoire.
Noter que Booch utilise le terme «statique» pour caractériser la taille de l'objet, ce qui est trop limitatif. Ada permet de créer des objets dont la taille est déterminée lors de leur élaboration, mais néanmoins dynamique.

Cet aspect concerne les choix fondamentaux de représentation. Typiquement, une liste limitée sera représentée par un tableau, alors qu'une liste non limitée sera représentée par une chaîne de variables pointées. La forme bornée est adaptée au cas où l'on connaît a priori le nombre maximum d'éléments, tout en sachant qu'il est très peu probable que ce nombre soit atteint. Elle peut correspondre à un type à discriminant. Ici encore, une plus grande généralité se paie généralement en termes de rapidité d'exécution.

Récupération

modifier

Le troisième critère de classification se fonde sur les propriétés de la gestion de la mémoire par le composant. Ce critère est fondamental pour les structures de données, mais peut être applicable à d'autres sortes de composants. Il n'a de signification que pour les composants de type «non limité» selon le critère précédent. La mémoire utilisée par le composant peut être:

  • Non gérée. Le composant ne se préoccupe pas de la récupération de l'espace mémoire devenu disponible. La mémoire ne sera récupérée que si l'exécutif a prévu un dispositif « ramasse-miettes » (garbage collector), sinon elle sera perdue.
  • Gérée. Le composant gère et récupère la mémoire libérée.
  • Contrôlée. Le composant, de type séquentiel, gère et récupère la mémoire libérée. Bien que séquentiel, le processus de récupération de la mémoire est tout de même protégé contre les accès simultanés, de façon à garantir un comportement correct en cas d'utilisation par plusieurs tâches travaillant sur des objets distincts.

Ici encore, on pourrait espérer que les composants non limités soient du type géré ou contrôlé. Cependant, le surcoût peut être important, et non justifié dans certains cas (par exemple si les structures ne décroissent jamais).

À faire... 

Parler plus des types contrôlés

Les types contrôlés devraient rendre l'écriture de composants contrôlés beaucoup plus facile en Ada 95 qu'en Ada 83. Ces types permettent de définir une procédure de finalisation qui est appelée automatiquement lors de la destruction d’un objet; il est alors possible de rendre automatiquement tout l’espace mémoire associé.

Parcours

modifier

Ce critère de classification qui figure dans [Boo87] n'en est pas vraiment un; sa présence provient de ce que Booch n'a pas considéré les gestionnaires de données comme une classe à part de type de donnée abstrait. Nous le mentionnons ici par complétude.

  • Non itérateur. Le composant ne comporte pas d'itérateur.
  • Itérateur. Le composant comporte un itérateur.

Si l'on sépare les structures de données, il est évident que celles-ci, et seulement celles-ci, doivent avoir un itérateur... sauf exception (les piles sont typiquement des structures de données sans itérateur).

Relations entre composants

modifier

Tous les composants ne naissent pas égaux. Certains sont totalement généraux et indépendants de leur contexte, d'autres très spécifiques. Nous allons maintenant caractériser les composants en termes de généralité d'usage.

Composants indépendants

modifier

Ces composants sont caractérisés par le fait qu'ils ne référencent que les éléments standard du langage: types et paquetages prédéfinis. Ils sont donc a priori portables sans problèmes. Ces composants sont l'analogie logicielle des composants électroniques les plus élémentaires: résistances, condensateurs, transistors. Ils peuvent être combinés entre eux sans difficulté. Autant que possible, on essayera de rendre les composants indépendants, car ce sont les plus généraux et les plus portables. La plupart des composants «classiques» commercialement disponibles appartiennent à cette catégorie: gestionnaires de données, bibliothèques... L'utilisation de composants généraux est en principe autorisée sans contrainte, c'est-à-dire que le choix de tels composants ne constitue pas une décision de conception engageant le projet.

On pourra souvent rendre indépendant un composant qui semble dépendre d'un type de donnée particulier en rendant le composant générique et en important le type en question en tant que paramètre générique. Supposons par exemple une fonction qui dépendrait d'un type Vecteur et qui fournirait la somme des éléments:

with Définition_Vecteurs; use Définition_Vecteurs;
function Somme (Item : Vecteur) return Float;

On pourrait rendre sa spécification indépendante en la transformant en générique comme ceci:

generic
	type Composant is private;
	with function "+" (Gauche, Droite : Composant)
		return Composant is <>;
	type Index is (<>);
	type Vecteur is array (Index range <>) of Composant;
function Somme (Item : Vecteur) return Composant;

Sous-systèmes

modifier

La notion de sous-système a été développée par Booch [Boo87]. Elle correspond à la nécessité de diminuer la complexité d'un composant en le décomposant hiérarchiquement en un certain nombre de modules. L'un d'entre eux joue le rôle de point d'entrée, et c'est le seul utilisé par les entités clientes du composant. Les autres sont spécifiques du composant. Il s'agit donc bien d'une décomposition hiérarchique du composant, et non de l'utilisation par un composant d'autres composants généraux. Bien sûr, rien n'empêche les éléments du sous-système d'utiliser également des composants réutilisables, mais ceux-ci n'appartiendront pas logiquement au sous-système.

Il n'existait pas en Ada 83 de moyen pour contrôler au niveau du langage que les composants d'un sous-système n'étaient pas utilisés depuis l'extérieur du sous-système. C'est pourquoi Ada 95 a introduit la notion d'unités enfants privées : de telles unités ne sont utilisables que par leur père et leurs frères. Par exemple:

package Parent is
	...
end Parent;
private package Parent.Enfant_1 is
	...
end Parent.Enfant_1;
private package Parent.Enfant_2 is
	...
end Parent.Enfant_2;
private package Parent.Enfant_2.Petit_fils is
	...
end Parent.Enfant_2.Petit_fils;

Le corps du paquetage Parent peut référencer les paquetages Parent.Enfant_1 et Parent.Enfant_2. Chacun des deux enfants peut référencer l'autre, mais seul le corps de Parent.Enfant_2 peut référencer Parent.Enfant_2.Petit_fils. En revanche, le Petit_Fils peut référencer son «oncle» Parent.Enfant_1. Noter que seul le corps d'un parent peut référencer son enfant, car la spécification du parent doit être compilée avant celle de l'enfant. Aucune unité en dehors de celles qui descendent de Parent ne peut référencer les enfants privés.

Avec quelques différences dans la façon de les formaliser, ces sous-systèmes correspondent à la notion d'objet non terminal de la méthode HOOD [Hoo93], qui sert également à structurer hiérarchiquement les niveaux d'abstraction des objets. L'objet parent joue alors le rôle de point d'entrée, les autres modules constituant les objets enfants. Une hiérarchie HOOD se représente très bien au moyen d'unités hiérarchiques, bien que ceci ne fasse pas partie de la norme HOOD actuelle qui se limite aux possibilités d'Ada 83.

Familles de composants

modifier

Nous ne le répéterons jamais assez: il n'existe jamais de solution unique à un problème d'informatique. Même une simple gestion de chaînes de caractères peut être réalisée de plusieurs façons[1]. Lorsque des composants quelque peu évolués doivent être développés, ils utilisent souvent les services d'autres composants. Pour pouvoir coopérer et échanger des données entre eux, il est nécessaire qu'ils utilisent les mêmes abstractions de base. Nous dirons que des composants appartiennent à une même famille s'ils utilisent les mêmes types de base et qu'ils sont donc combinables entre eux sans conversion de données. Ces composants sont l'analogie logicielle des familles de circuits intégrés, TTL, ECL, etc. où, de façon similaire, le branchement de deux composants de la même famille ne pose aucun problème, alors que la connexion de deux circuits de familles différentes est possible, mais nécessite une adaptation.

On trouvera de telles familles lorsque des composants dépendent d'un choix de représentation des abstractions de base: chaînes de caractères, vecteurs, accès aux bases de données, systèmes de communication sur réseaux. On en trouvera également pour les interfaces utilisateurs (terminaux virtuels, systèmes de fenêtrage), ainsi que pour des conventions générales (comme le mécanisme de traitement et de gestion des erreurs). Lors du développement d'un composant logiciel, s'il n'est pas possible de le rendre indépendant, il faudra identifier clairement sa famille, c'est-à-dire les autres composants généraux qui sont «entraînés» par ce composant. On essayera bien entendu d'en limiter le nombre.

Les nouveaux paquetages prédéfinis d'Ada 95 (chaînes, nombres complexes, bibliothèques mathématiques...) permettent de rendre indépendants des composants qui ne l'étaient pas auparavant.

Lors des phases préliminaires de la conception, le chef de projet doit faire le choix des familles de composants à utiliser, comme l'électronicien choisit une famille technologique. Il doit tenir compte des contraintes de son projet, mais aussi des familles auxquelles appartiennent les composants qu'il souhaite utiliser, pour minimiser les conversions de données. Dans une entreprise, on peut imposer l'utilisation d'un petit nombre de familles de composants afin de maintenir une interopérabilité maximale des modules. Un tel ensemble de familles «homologuées» constitue une véritable culture d'entreprise.

  1. C'est si vrai qu'Ada 95 fournit trois paquetages de gestion de chaînes, sensiblement différents.

Composants liés

modifier

Il est courant que plusieurs niveaux de services gravitent autour d'une même abstraction. En particulier, lorsque l'on définit un type de donnée abstrait, on trouve des fonctionnalités fondamentales et d'autres qui, quoique très utiles, peuvent être construites par combinaison des fonctionnalités fondamentales. Plus ces fonctionnalités annexes sont évoluées, plus elles tendent à être spécifiques d'un besoin particulier, et leur nombre risque d'exploser rapidement. Aussi est-il préférable de limiter les fonctionnalités fournies dans la définition d'un type de donnée abstrait aux seules opérations fondamentales. Il se pose donc la question de la façon de définir les opérations complémentaires lorsque l'abstraction fondamentale ne fournit pas directement les services nécessaires. Trois solutions sont alors possibles[1]: définir une nouvelle entité indépendante, définir un type englobant ou enrichir les fonctionnalités.

  1. Définir une nouvelle entité indépendante
  2. On définit un nouveau type de donnée abstrait, indépendant du type original, mais muni des fonctionnalités supplémentaires. Il est possible d'utiliser simultanément les deux types de données abstraits, qui n'ont aucun lien conceptuel. Il y a duplication d'un certain nombre de fonctionnalités. Cette situation est illustrée par la figure 27. Ce cas se produit lorsqu'il existe plusieurs notions différentes auxquelles on se réfère sous un nom commun. Le besoin de nouvelles fonctionnalités provient de ce que les propriétés naturelles du nouveau type ne correspondent pas à celles de l'ancien.
     
    Figure 27: Composants indépendants
    Figure 27: Composants indépendants

    Par exemple, nous avons un paquetage gérant les aspects liés au temps (Calendar). Si l'on a besoin de la notion de «temps virtuel» (pour faire un programme de simulation à événements discrets par exemple), il ne faut surtout pas tenter d'utiliser le type Calendar.Time; la notion de temps simulé est conceptuellement différente de celle de temps «réel». Pourtant, les deux notions ont des choses en commun: la notion d'heure courante, les opérations arithmétiques entre temps et durée... On définira donc un nouveau type, indépendant de Calendar.Time, mais on fournira des opérations identiques dans leurs spécifications à celles de Calendar, de façon à éviter à l'utilisateur d'avoir à apprendre deux formes d'interfaces différentes.

  3. Définir un type englobant
  4. Le nouveau type de donnée abstrait est différent du type d'origine, doté de nouvelles fonctionnalités et situé à un plus haut niveau d'abstraction; son implémentation utilise le type de plus bas niveau. On interdit l'utilisation simultanée des deux niveaux. Cette situation est illustrée par la figure 28.
     
    Figure 28: Composant englobant
    Figure 28: Composant englobant

    Par exemple, lorsque l'on conçoit des interfaces utilisateur, il est nécessaire d'afficher des messages dans différentes couleurs. Mais il existe deux niveaux de couleur: les couleurs physiques (rouge, bleu, jaune, ...) et les couleurs «logiques» (couleur d'un message d'erreur, couleur de fond d'une boîte de dialogue, couleur de bordure...). Pour permettre à l'utilisateur de changer ses couleurs à volonté, le programme ne devra utiliser que des couleurs logiques, que l'implémentation se contentera de traduire en couleurs physiques.

  5. Enrichir les fonctionnalités
  6. On fournit un paquetage de type «collection de sous-programmes» qui procure des fonctionnalités complémentaires, comme illustré par la figure 29.
     
    Figure 29: Composant complémentaire
    Figure 29: Composant complémentaire

    En Ada 83, il y aurait une dépendance de la spécification du nouveau paquetage vers celle du paquetage contenant le type d'origine et le paquetage complémentaire ne pourrait utiliser les informations privées du paquetage d'origine; les fonctionnalités fournies devraient donc être construites par combinaison des fonctionnalités de base. En Ada 95, on utilise des unités enfants publics. Celles-ci s'écrivent comme les enfants privés, mais sans mettre le mot private en tête. Ces unités sont alors accessibles depuis n'importe quelle autre unité, comme si ce n'étaient pas des enfants; l'avantage pour l'implémentation est qu'elles ont accès depuis leur propre partie privée et leur corps à la partie privée de leur parent: les fonctionnalités complémentaires peuvent donc être écrites de façon beaucoup plus efficace.

    L'utilisateur doit employer le type d'origine en même temps que le paquetage de fonctionnalités complémentaires; les deux niveaux cohabitent et doivent être utilisés ensemble. Ceci se fait automatiquement en Ada 95, car une clause de la forme with Parent.Enfant implique l'utilisation du parent aussi bien que de l'enfant.

    Cette structure est fréquente avec les types de données abstraits. Ceux-ci possèdent généralement quelques fonctionnalités réellement fondamentales et indépendantes de l'application, et d'autres plus accessoires. Il est ainsi possible de séparer clairement ces deux aspects. Par exemple, un paquetage de nombres complexes fournira directement les opérations habituelles; en revanche, les entrées-sorties ne font pas fondamentalement partie de l'abstraction: il est préférable de mettre celles-ci dans un paquetage séparé. Noter qu'il est également possible de fournir plusieurs variantes du même service: il suffit d'avoir plusieurs enfants avec exactement les mêmes spécifications (mais bien sûr des corps différents).

  1. Cette classification a été établie dans cadre des activités d'Ada-France.

Classification des spécifications et des implémentations

modifier

La spécification et le corps d'un même composant n'appartiennent pas nécessairement à la même classe. Par exemple, une spécification peut ne faire référence à aucun autre paquetage, alors que son implémentation utilise une «technologie» qui la fait appartenir à une famille. Dans ce cas, le composant sera «indépendant» du point de vue de l'utilisateur et appartiendra à la famille pour la maintenance. L'appartenance de l'implémentation à une famille doit cependant être documentée dans les caractéristiques annexes d'implémentation, car il y a un risque d'incompatibilité entre la famille utilisée pour l'implémentation et les choix du projet. Par exemple, si le projet a choisi une certaine famille de terminal virtuel, il ne pourra utiliser un composant dont l'implémentation exigerait une famille de terminal virtuel différent.

Certains composants dont la nature exige une implémentation dépendant d'une famille peuvent fournir plusieurs variantes, correspondant aux implémentations selon différentes familles. Un système de fenêtrage peut ainsi avoir une variante correspondant à un terminal virtuel ANSI, une autre à une gestion d'écran IBM-PC et une troisième à une implémentation XWindow.

Variantes, versions et systèmes de compilation

modifier

En général, il n'existera pas un seul exemplaire d'un composant logiciel. Tout d'abord, pour un même service (abstrait) rendu, on peut disposer de plusieurs implémentations, par exemple en fonction des différentes espèces définies dans la taxonomie de Booch (protection ou non contre les accès concurrents, types de gestion de la mémoire...), de différentes conventions de présentation des données à l'écran... Nous proposons d'appeler variantes un ensemble d'unités partageant la même spécification, mais différant dans leurs contraintes de réalisation. Idéalement, toutes les variantes devraient avoir des spécifications Ada identiques, sauf pour le nom des unités. En pratique, elles différeront au moins par leur partie privée. De plus, des sous-programmes supplémentaires peuvent être introduits, par exemple pour la gestion des variantes «contrôlées». Il faut donc considérer que la spécification d'un paquetage comporte logiquement deux parties, que nous appellerons spécification principale et spécification annexe. La spécification principale n'est dictée que par les contraintes de l'abstraction considérée, sa documentation peut (et doit) être commune à toutes les variantes. La spécification annexe regroupe des fonctionnalités liées à des contraintes d'implémentation particulières; une documentation spécifique par variante doit être fournie. La lecture de la documentation de la spécification annexe ne doit en aucun cas être nécessaire pour comprendre le fonctionnement abstrait du composant.

Une entreprise développera en général ses composants sur une grande variété de triplets hôte-cible-compilateur. Nous appellerons un tel triplet un système de compilation. On peut le définir en disant que deux programmes d'un même système de compilation peuvent toujours être compilés dans une même bibliothèque de programme, et donc utiliser les mêmes composants sans recompilation. L'idéal est bien entendu de disposer de toutes les variantes de tous les composants logiciels sur tous les systèmes de compilation utilisés dans l'entreprise. En pratique, ce sera rarement le cas, car certains composants peuvent être spécifiques de certaines configurations (l'accès à un service de courrier électronique a peu de chances d'être utile avec un système pour carte autonome embarquée...). De plus, l'écriture de certains corps[1] peut faire appel à des éléments dépendant de l'implémentation, et donc nécessiter que l'on conserve des sources différents selon le système de compilation.

Enfin, comme tout logiciel, un composant peut être amené à évoluer dans le temps: il peut donc y avoir différentes versions successives d'un même composant. Ces trois degrés de liberté d'un composant logiciel sont orthogonaux: chaque composant devra être repéré par ses caractéristiques selon les composantes «variantes», «système de compilation», «version».

  1. Jamais des spécifications!

Exercices

modifier
  1. Prendre un livre décrivant des composants matériels (comme un TTL Data Book) et chercher les analogies avec les classifications établies dans ce chapitre.
  2. Quels sont les points importants de l'implémentation d'un composant logiciel qui doivent être portés à la connaissance de l'utilisateur ? Penser aux problèmes liés au parallélisme et à la gestion mémoire.
  3. Étudiez le système de gestion de bibliothèques multiples de votre compilateur Ada favori et proposez une façon de l'utiliser pour faciliter l'organisation des différentes familles de composants logiciels.

Règles pour l'écriture des composants logiciels

modifier

Nous allons présenter ici quelques règles particulièrement importantes pour l'écriture des composants logiciels. Bien sûr, elles sont également applicables aux modules non spécifiquement réutilisables, mais c'est avec les composants réutilisables qu'elles doivent être appliquées le plus rigoureusement.

Définition du comportement

modifier

Comme nous l'avons vu, une caractéristique du composant logiciel est que l'auteur ne dispose d'aucun moyen de pression sur l'utilisateur. Le comportement doit donc être totalement défini pour toute combinaison possible des valeurs d'entrée. Imaginons par exemple une procédure qui utilise un de ses paramètres comme quotient dans son algorithme :

procedure P (X : Integer) is
begin
	...
	Y := 1/X;
	...
end P;

Généralement, on documente que «la procédure ne fonctionne pas si elle est appelée avec la valeur 0». Cependant, une partie du calcul a pu être effectuée, éventuellement avec modification partielle de l'état global, avant le point où l'exception Constraint_Error est levée à cause de la division par 0. Le risque d'obtenir un état incorrect est donc sérieux. Par conséquent, si une valeur n'est pas autorisée, le sous-programme doit effectuer un test de validité avant toute autre opération. Dans l'exemple, on débuterait la procédure par :

begin
	if X = 0 then
		raise Constraint_Error;
	end if;
	...

L'effet extérieur est apparemment le même, sauf qu'il est maintenant possible de documenter précisément que «l'appel avec la valeur 0 lève l'exception Constraint_Error et n'a aucun autre effet». D'une mention d'un non-fonctionnement de la procédure nous sommes passés à une spécification de comportement pour une valeur particulière. Si 0 est la seule valeur interdite, il n'est pas possible de faire mieux. Mais supposons maintenant que les valeurs négatives soient également interdites. La spécification peut alors devenir :

procedure P (X : Positive);

Il n'est désormais plus nécessaire de faire de test explicite, puisque celui-ci est assuré par les règles du langage. Mieux : il n'est plus nécessaire de documenter la levée de l'exception, puisque ce comportement résulte de la seule spécification syntaxique du sous-programme. Notons au passage que le test engendré automatiquement par le compilateur a de bonnes chances d'être plus efficace qu'un test explicite. Tirons quelques règles de cet exemple :

  • Le comportement d'un sous-programme doit être défini pour toute combinaison possible des valeurs des paramètres. Nous appelons ici combinaison possible les valeurs de paramètres résultant de la seule application des règles du langage.
  • Une spécification de comportement peut être la levée d'une exception. Le diagnostic des valeurs incorrectes doit être effectué le plus tôt possible, et en tout état de cause la levée d'une exception doit être le seul effet de l'appel du sous-programme sur l'environnement.
  • Il est préférable d'exprimer toute contrainte sur les paramètres au moyen des règles du langage, notamment par une utilisation judicieuse des sous-types. Le test explicite ne doit être utilisé que si le langage ne permet pas d'exprimer directement la contrainte.

Notons que pour l'application de la première règle, deux solutions sont possibles dans le cas de valeurs pour lesquelles le comportement du sous-programme est non spécifié : il faut soit restreindre plus, c'est-à-dire empêcher l'appel avec la valeur incorrecte, soit spécifier plus, c'est-à-dire lui donner une sémantique. Cette dernière solution est par exemple celle que nous avons choisie dans notre exemple d'introduction (la procédure Permute), lorsque la valeur de Coupure n'appartient pas à l'intervalle de définition de la chaîne : nous avons alors décidé de renvoyer la chaîne inchangée. Nous avons donc complété la sémantique du sous-programme. Nous aurions aussi bien pu décider de lever une exception dans ce cas.

Une possibilité intéressante d'Ada concerne les unités génériques. Ce que nous avons dit de la vérification des paramètres formels de sous-programmes s'applique également à la vérification des paramètres formels génériques. Le langage nous permet bien sûr de donner des sous-types appropriés aux paramètres génériques. Mais que faire si les contraintes ne peuvent s'exprimer au moyen des règles du langage? Supposons par exemple un paquetage générique de génération de nombres aléatoires :

generic
	type Flottant is digits <>;
	Germe : in Positive;
package Aleat is
	...

Certains algorithmes de génération exigent la présence d'un germe strictement positif (ce que nous avons exprimé par l'utilisation du sous-type Positive) impair. Cette dernière condition n'est pas exprimable dans le langage, il est donc nécessaire de la vérifier par programme. Il suffit de se rappeler que les instructions du corps d'un paquetage générique sont exécutées au moment de l'instanciation. Nous pouvons donc mettre tous les tests nécessaires dans la partie instruction du corps de paquetage :

package body Aleat is
	...
begin
	if Germe mod 2 = 0 then
		raise Constraint_Error;
	end if;
end Aleat;

Notons que pour l'utilisateur qui tenterait d'instancier le générique avec une valeur négative, Constraint_Error serait levée par le compilateur, alors qu'avec une valeur paire elle serait levée par le test du corps de paquetage; cependant, ces deux comportements sont indiscernables extérieurement, et il suffit de documenter que toute instanciation avec des valeurs incorrectes lève l'exception. Les règles du langage garantissent alors qu'aucun paquetage instancié avec des paramètres incorrects ne peut exister. Retenons que :

En aucun cas il ne faut imposer un comportement à l'utilisateur sans le vérifier dans le composant.

Gestion des situations exceptionnelles

modifier

Comme nous venons de le voir, un composant logiciel doit diagnostiquer un certain nombre de situations exceptionnelles. Bien que plusieurs techniques soient disponibles (nous les passerons en revue dans la quatrième partie), le mécanisme d'exceptions doit être utilisé dans ce cas; mieux, l'on peut dire que les exceptions ont été spécialement conçues pour permettre l'écriture de composants logiciels.

Pour bien comprendre ce point, rappelons ce qui se passe par exemple en FORTRAN lorsqu'une erreur d'exécution survient. Nous avons connu une bibliothèque mathématique FORTRAN qui, lors d'une erreur de paramètre (ARCSIN(2.0) par exemple), envoyait un message d'erreur sur le terminal. Or rien ne prouve qu'il y ait un terminal! Ou plus vraisemblablement, le terminal peut être dans un état (mode graphique) qui ne lui permette pas d'imprimer un message. Le résultat peut être catastrophique, car le message met le terminal dans un état «bizarre»... et le programme appelant n'est pas prévenu que la fonction n'a pu réaliser le travail demandé. En fait, le problème vient de ce que le composant diagnostique une faute de paramètres, mais que l'origine du problème se trouve dans l'appelant; le composant n'est donc pas à même de prendre une décision quant au traitement de l'erreur : par définition, le composant logiciel ignore tout du contexte dans lequel il est appelé. Il est donc nécessaire de signaler à l'appelant de façon non équivoque que le traitement demandé n'a pu avoir lieu, et c'est bien la raison d'être des exceptions.

Si cet appelant est lui-même un autre composant logiciel, il ne sait ni pourquoi l'exception a été levée, ni quel traitement effectuer : il doit donc laisser filer l'exception ou, à la rigueur, la transformer en une exception à lui. Lorsque l'exception pénètre dans un module spécifique du projet, elle doit être rattrapée et traitée selon les conventions du projet. On peut donc résumer ces principes au moyen des règles suivantes :

  • Un composant indépendant doit soit lever une exception, soit rendre exactement le service demandé.
  • Un composant indépendant ne doit pas faire disparaître une exception.
  • Le premier niveau non indépendant est chargé du traitement de l'exception.

Initialisation

modifier

Initialisation du composant

modifier

Les bibliothèques, les machines abstraites et beaucoup d'autres composants nécessitent une initialisation avant de pouvoir être utilisés de façon correcte. Avec la plupart des langages de programmation, il existe une procédure d'initialisation, et la documentation avertit généralement qu'«il faut l'appeler avant toute utilisation». Bien entendu, l'utilisateur ne sait rien de ce qui se passe si jamais il omet de l'appeler, mais il se doute que cela ne pourrait apporter qu'un mauvais fonctionnement. Et que se passe-t-il si la procédure d'initialisation est appelée deux fois? Mystère, bien qu'en général ce soit aussi nocif que de ne pas l'appeler du tout.

Tout le problème est que dès que l'on utilise des composants un peu évolués, ils font eux-mêmes appel à d'autres composants, qui doivent également être initialisés. Si un composant A utilise un composant B, il paraîtra logique d'inclure l'appel à l'initialisation de B dans la procédure d'initialisation de A.

 
Figure 30 : Dépendances à l'initialisation
Figure 30 : Dépendances à l'initialisation

Hélas, un composant général est susceptible d'être utilisé par plusieurs autres composants. Supposons un schéma comme celui de la figure 30. Le composant B est utilisé par A et C, eux-mêmes utilisés par le programme principal. Logiquement, les procédures d'initialisation de A et de C doivent faire appel à l'initialisation de B, et la procédure d'initialisation de C sera appelée deux fois. Bien sûr, A ne connaît pas C, et réciproquement, donc il ne peut supposer que l'initialisation de B est effectuée par le collègue. La seule solution est de ne pas effectuer l'initialisation des composants utilisés, et de se reposer sur l'utilisateur en documentant qu'avant d'appeler l'initialisation de A et de C, il faut appeler celle de B... Outre que cette solution fragilise le système, elle rend une partie de l'implémentation visible : l'utilisateur de A et de C n'était pas nécessairement au courant de l'existence de B, et la nécessité d'appeler cette initialisation va créer des liens de dépendance pour cette seule fonction, qui dénaturent l'architecture du projet.

La meilleure solution consiste donc en l'auto-initialisation des composants. Pour les bibliothèques et les machines abstraites, il est facile de faire effectuer l'initialisation par les instructions du corps de paquetage. Si l'on souhaite que l'initialisation ait lieu plus tardivement que l'élaboration du corps, il faut garder simplement une variable globale spécifiant que la bibliothèque n'est pas initialisée. Cette variable sera testée par tous les points d'entrée publics, et l'on déclenchera l'initialisation lors du premier appel d'un module.

Quant aux types de données abstraits, ils sont généralement représentés par des types articles, dont les composants cruciaux peuvent contenir des valeurs d'initialisation. N'oublions pas que l'initialisation d'un composant de type article peut parfaitement être dynamique et contenir n'importe quelle expression! Supposons par exemple un type de donnée dont chaque instance doit se faire attribuer un numéro d'identification unique. Habituellement, la documentation dirait qu'il faut appeler une procédure d'initialisation après avoir déclaré chaque variable. Il existe un risque sérieux de mauvais fonctionnement si une variable n'est pas initialisée ou initialisée deux fois. En Ada, nous avons la possibilité de déclarer l'objet comme ceci :

package Objet_Identifié is
	type Objet is private;
	-- Opérations sur l'objet...
private
	type ID_Type is new Positive;
	function Identification return ID_Type;
	type Objet is
		record
			ID : ID_Type := Identification;
			-- autres composants ...
		end record;
end Objet_Identifié;

Les mécanismes du langage vont garantir que la fonction d'initialisation sera appelée une fois pour chaque objet, et qu'il n'est pas possible de l'appeler autrement que pour l'initialisation d'un objet (puisqu'elle est déclarée dans la partie privée).

Pour des besoins plus complexes, Ada 95 permet de définir des procédures d'initialisation, de finalisation et de contrôle de l'affectation au moyen du paquetage Ada.Finalization.

Dans certains cas, il n'est pas possible d'effectuer une initialisation automatique, et l'on doit recourir à l'utilisation de sous-programmes explicites. Certaines méthodes de conception (HOOD) imposent également des initialisations explicites. Dans ce cas, il faut prendre des précautions supplémentaires pour garantir le bon fonctionnement. Une technique courante est l'utilisation de jetons : lors de l'appel à une procédure d'une bibliothèque, on doit présenter une valeur (généralement d'un type limité pour éviter les contrefaçons), valeur fournie par la procédure d'initialisation. La technique utilisée pour la gestion des fichiers («ouverture» du fichier, suivie de la présentation à chaque procédure d'entrée-sortie du fichier «ouvert») n'est qu'une variante de la technique du jeton. Noter que pour être parfaitement sûre, cette technique nécessite une auto-initialisation du jeton, qui ne peut être assurée que par les techniques précédentes. L'initialisation automatique reste donc nécessaire pour amorcer le mécanisme.

Si aucune des techniques précédentes n'est applicable, on est obligé de s'en remettre à la méthode classique de la procédure d'initialisation. Le composant doit alors mettre en place un mécanisme pour vérifier qu'il est initialisé correctement, grâce à une variable globale interne. La procédure d'initialisation lèvera une exception si elle est appelée sur un module déjà initialisé, de même que l'appel de toute autre procédure sur un module non initialisé. Ceci impose de séparer la fonction d'initialisation de la fonction de réinitialisation, séparation qui est d'ailleurs de toute façon souhaitable.

Les mêmes principes et les mêmes techniques sont applicables, en dehors des cas d'initialisation, chaque fois qu'il existe une dépendance temporelle dans l'ordre d'appel des sous-programmes. Les solutions seront choisies de préférence dans l'ordre suivant :

  • Ne pas introduire de dépendance temporelle, ou faire en sorte qu'elle soit cachée (ajustement automatique du comportement grâce à une utilisation judicieuse du langage).
  • Mettre des sécurités au niveau de l'objet : utilisation de jetons, «ouverture» d'objets...
  • Mettre des sécurités globales et rejeter les utilisations incorrectes.

Encore une fois, notons qu'en aucun cas la sécurité ne doit être assurée en supposant simplement que l'utilisateur a suivi les règles du mode d'emploi.

Elaboration du composant

modifier

Si l'on utilise des initialisations explicites, l'ordre dans lequel elles s'effectuent est déterminé par l'utilisateur. Dans le cas d'initialisations automatiques, les règles du langage ne déterminent qu'un ordre partiel; l'ordre total dépend en partie de l'implémentation. Il est possible de contrôler plus finement cet ordre, appelé ordre d'élaboration, au moyen du pragma Elaborate_All qui demande que certains modules (ainsi que ceux dont ils dépendent de façon transitive) soient élaborés avant le module où le pragma apparaît.

En Ada 83 n'existait que le pragma Elaborate, qui n'était pas transitif : si le paquetage utilisé dépendait lui-même d'autres paquetages dont l'élaboration était nécessaire, il fallait les nommer explicitement. Le pragma Elaborate_All a remédié à ce problème et doit normalement être utilisé à la place de l'ancien pragma Elaborate, sauf dans quelques cas d'ordre d'élaboration très spéciaux.

Bien que ce soit rare, il existe des cas où un ordre d'élaboration incorrect peut conduire à la levée de l'exception Program_Error[1]. Il faut alors faire preuve d'une vigilance particulière, car il est parfaitement possible d'obtenir un comportement correct sur les programmes de tests, et qu'une fois en service le composant refuse de fonctionner. Le risque est cependant limité, car le programme ne démarre même pas.

Un autre cas de dépendance à l'élaboration est fourni par l'exemple de l'Objet_Identifié du paragraphe précédent : toute déclaration d'un objet du type Objet par l'utilisateur lèvera l'exception Program_Error si le corps de Objet_Identifié n'a pas été élaboré. Or cette dépendance n'est pas visible par l'utilisateur si on ne lui communique pas le contenu de la partie privée du paquetage. Il convient donc de documenter la nécessité de mettre un pragma Elaborate_All dans tout module déclarant des objets de ce type.

  1. Ne jetons pas la pierre à Ada : ceci est dû à la présence d'initialisations dynamiques qui n'existent pas dans les autres langages de programmation.

Définition de génériques

modifier

La notion de générique est intimement liée à celle de composant logiciel, car elle permet de généraliser un algorithme en le rendant applicable à tout un ensemble de types de données.

Développement de générique

modifier

Supposons que nous écrivions une fonction pour trouver le plus grand de deux nombres entiers :

function Max (Gauche, Droit : Integer) return Integer is
begin
	if Gauche < Droit then
		return Droit;
	else
		return Gauche;
	end if;
end Max;

Ne pouvons-nous écrire cette fonction que pour le type Integer ? Bien entendu non. La seule propriété nécessaire est l'existence d'une relation d'ordre (opérateur "<"). Nous pouvons donc généraliser cette fonction à tout type de donnée muni d'un opérateur "<" :

generic
	type Donnée(<>) is limited private;
	with function "<"(Gauche, Droit : Donnée) return Boolean
		is <>;
function Max (Gauche, Droit : Donnée) return Donnée;
La boîte utilisée dans Donnée(<>) est une nouveauté Ada 95 qui exprime que le composant peut être instancié même avec des types non contraints.

Nous avons ici généralisé l'algorithme en analysant quelles étaient les propriétés minimales nécessaires pour l'écrire. Ces propriétés sont exprimées par les paramètres génériques de l'unité. Les règles d'Ada vérifieront que le type effectif utilisé pour l'instanciation possède au moins ces propriétés minimales, garantissant la validité de l'instanciation.

On commencera donc rarement par écrire un composant générique. Un projet éprouvera le besoin de développer un certain algorithme, généralement pour un type de donnée particulier. Par la suite, on accroîtra la réutilisabilité du composant en analysant les propriétés du type de donnée réellement utilisées, et en passant le type et ses propriétés nécessaires en générique.

Bien sûr, tous les composants ne sont pas candidats à la transformation en générique. En premier lieu, on cherchera à rendre génériques les types de données abstraits de «deuxième niveau», c'est-à-dire ceux qui utilisent un autre type pour leur implémentation (type numérique en particulier).

Autres types de génériques

modifier

Deux sortes d'unités génériques échappent à ce schéma général d'évolution, et sont conçues en génériques dès le début : les unités paramétrables et les structures de données.

Les unités paramétrables ne sont génériques que pour permettre de passer des facteurs de dimensionnement, comme des tailles maximales de tables. Ce sont en quelque sortes de «faux» génériques, en ce sens qu'elles ne correspondent pas à la notion de type de donnée abstrait construit «par-dessus» un autre type. Ces unités ne comportent en principe que des paramètres in, et ne sont en général instanciées qu'une seule fois dans un projet.

Inversement, les gestionnaires de données sont fondamentalement prévus pour manipuler d'autres types de données dont on ignore tout a priori. Ils seront donc conçus comme génériques dès le début.

Génériques et exceptions

modifier

Il existe plusieurs points spécifiques à l'utilisation des exceptions dans/par les composants génériques.

Tout d'abord, lorsqu'une exception est déclarée dans la partie visible d'un paquetage générique, chaque instanciation provoque la création d'une exception différente; ceci n'est en général pas souhaitable. On regroupera donc souvent les exceptions dans un paquetage annexe non générique, suivant l'exemple du paquetage Ada.IO_Exceptions pour les entrées-sorties.

Ensuite, lorsqu'un sous-programme est passé en paramètre générique, l'unité générique doit considérer la possibilité de levée d'une exception par le sous-programme effectif, sous-programme sur lequel elle n'a aucun pouvoir, car il est fourni par l'utilisateur. En général, ces sous-programmes participent à la réalisation d'une fonctionnalité de l'unité générique; un échec de ce sous-programme ne peut signifier qu'un échec de la fonctionnalité utilisatrice. Il faudra en général laisser «filer» l'exception, et donc exclure tout traite-exception «when others» dans l'appelant, sauf si celui-ci relève soit la même exception, soit une exception spécifique du paquetage.

Enfin, on ressent parfois le besoin de faire lever par le générique une exception appartenant au domaine de l'utilisateur. Pourquoi? Parce que l'on se trouve dans le cas où un générique diagnostique une erreur, mais où la décision de l'action à prendre appartient à l'utilisateur. En fait, lever une exception de l'utilisateur est bien trop restrictif : rien ne dit que la réaction souhaitée soit justement une simple levée d'exception! Ceci nous conduit à une meilleure solution : importer une procédure de traitement d'erreur. Celle-ci pourra effectivement lever une exception[1], ou faire tout autre traitement approprié. L'utilisation des valeurs par défaut des procédures est tout à fait utile dans ce cas. Par exemple, une bibliothèque effectuant des traitements mathématiques pourrait se présenter ainsi :

procedure Action_Par_Défaut (Valeur_Fournie : out Float) is
begin
	raise Constraint_Error;
end Action_par_Défaut;
with Action_Par_Défaut;
generic
	with procedure Si_Débordement(Valeur_Fournie: out Float)
		is Action_Par_Défaut;
package Fonctions_Mathématiques is ...

En cas de débordement, on appelle Si_débordement. Par défaut, ceci provoque la levée de Constraint_Error, mais l'utilisateur qui ne souhaite pas interrompre les calculs peut fournir une procédure Si_débordement explicite qui fournira une valeur de substitution (Float'LARGE ou 0.0 par exemple).

  1. Que le composant logiciel ne devra bien entendu surtout pas traiter !

Portabilités et dépendances à l'implémentation

modifier

La portabilité est un point fondamental pour un composant logiciel. Il existe pourtant des fonctionnalités qui dépendent nécessairement de l'implémentation. Mieux : de nombreux composants ont précisément pour but d'encapsuler ces dépendances, afin de rendre le reste de l'application totalement portable. Il importe donc d'identifier correctement ce qui est, ou n'est pas, portable, et de savoir de quelle portabilité il s'agit.

Portabilités a priori et a posteriori

modifier

Nous parlerons de portabilité a priori lorsque le résultat de l'exécution du programme peut être prédit d'après la seule lecture du code, de façon indépendante de l'implémentation. C'est en principe le cas le plus fréquent. Nous parlerons de portabilité a posteriori lorsque le résultat ne peut être déterminé indépendamment de l'exécution, mais que le logiciel a été prévu pour tenir compte des variations dues à l'implémentation. Il utilisera les attributs des types ou les constantes du paquetage System pour ajuster son fonctionnement aux particularités du système d'exécution.

Le paquetage System contient (par définition) les déclarations de tous les éléments qui dépendent de l'implémentation.

Les deux formes de portabilité sont acceptables, à condition que l'utilisation de l'une ou de l'autre résulte d'un choix délibéré, et non d'une insuffisance de spécification... qui conduit généralement à n'avoir aucune des deux! Nous allons illustrer ce point par un exemple. Supposons qu'un programme ait besoin de stocker des données sous forme de chaînes de caractères. Une taille limite doit être choisie pour ces chaînes. Une erreur fréquente serait de déclarer le type comme suit :

Max : constant := 40_000;  -- par exemple
type Donnée is new String(1..Max);

Une telle déclaration n'assure en effet aucune des deux portabilités! Il n'y a pas portabilité a priori, car le type String dépend de l'implémentation, et rien ne dit qu'il soit possible de déclarer une chaîne de 40000 caractères[1]. Il n'y a pas non plus portabilité a posteriori, car il n'existe aucun paramétrage en fonction de l'implémentation. Il faut donc déclarer le type comme ceci (portabilité à priori) :

Max : constant := 40_000;
type Index_Donnée is range 1..Max;
type Donnée is array (Index_Donnée) of Character;

ou bien comme ceci (portabilité à posteriori) :

type Donnée is new String;
Max : constant := Donnée'Last;

Noter que dans ce dernier cas, la constante Max est déterminée à partir du type Donnée, et non le contraire. Le programme s'ajuste aux possibilités de l'implémentation. On trouvera fréquemment cette distinction entre les deux types de portabilité dans les modules à nature numérique : on a le choix entre imposer une précision a priori en définissant les types de façon absolue :

type Donnée is digits 7;

auquel cas on peut déterminer la précision des calculs indépendamment de l'implémentation, ou bien utiliser les nombres «normaux» du matériel :

type Donnée is new Float;

La précision des calculs dépend alors de la machine, mais peut être déterminée après coup de façon portable à partir de l'attribut Donnée'Digits.

  1. Si le type Integer est sur 16 bits, les chaînes sont limitées à 32 767 caractères.

Dépendances à l'implémentation

modifier

Nous dirons d'une unité de compilation qu'elle dépend de l'implémentation si l'on a prévu, dès la conception, qu'il puisse être nécessaire de la récrire en cas de changement de l'un des éléments du système de compilation. Nous avons bien dit «récrire» et non «modifier» ou «adapter» : tout changement, même minime, dû à une différence d'implémentation doit être considéré comme une nouvelle variante du composant, indépendante de celle qui lui a donné naissance. Faute de quoi, on risque d'assister à des «effets de yo-yo[1]» menant tout droit à des modules qui ne fonctionnent plus sur aucune implémentation... Noter que des génériques du genre «paramétrable» peuvent permettre de ne maintenir qu'une seule variante pour des systèmes suffisamment voisins : on garde les parties communes dans le générique, et on fournit les parties spécifiques sous forme de sous-programmes importés.

On trouve des dépendances à l'implémentation pour réaliser des interfaces avec le matériel, avec le système d'exploitation hôte, ou avec des bibliothèques écrites dans d'autres langages. Dans tous les cas, il existe un objet réel sous-jacent: périphérique, service système, service de la bibliothèque. La meilleure solution consiste à définir trois couches d'interfaçage. On commence par définir un composant représentant aussi fidèlement que possible une abstraction de l'objet réel. Sa spécification, comme son corps, comportent des éléments dépendant de l'implémentation. Le but de cette couche est de libérer les couches suivantes de tous les éléments «pénibles» de l'interface. On trouve ensuite une couche représentant le service fourni par l'objet réel : sa spécification est indépendante de l'implémentation, mais pas son corps. Enfin, on trouve un composant représentant le besoin abstrait satisfait par le service : spécification et corps sont alors indépendants de l'implémentation.

Illustrons cette structure par un exemple : nous voulons accéder aux fonctions du haut-parleur d'un IBM-PC. Celui-ci est piloté par un port 8253. Pour y accéder, nous devons réaliser l'abstraction du plus bas niveau des instructions IN et OUT des processeurs INTEL :

package In_Out is
	type Périphérique	is range 16#00# .. 16#FF#;
	type Octet         is mod 256;
	for Octet'SIZE use 8;
	procedure In_8086	(Depuis : in  Périphérique; 
	                    Donnée : out Octet);
	procedure Out_8086 (Vers   : in  Périphérique;
	                    Donnée : in  Octet);
end In_Out;

Le corps sera écrit en assembleur ou fera appel au paquetage Ada.Machine_Code. Ce paquetage permet d'accéder à la fonction «son» :

package Contrôle_Son is
	type Fréquence is range 20..20_000;
	procedure Emettre (Note :    in Fréquence);
	procedure Emettre (Note :    in Fréquence;
	                   Pendant : in Duration);
	procedure Arrêter_Son;
end Contrôle_Son;

Le corps de ce paquetage dépendra de l'implémentation, car il faudra notamment connaître les adresses physiques du port d'entrée-sortie. Cette deuxième couche nous permet de réaliser un paquetage totalement abstrait :

package Musique is
	type Gamme is 
		(Silence,  Ut,  Ut_Dièse,  , Ré_Dièse, Mi, Fa,
		 Fa_Dièse, Sol, Sol_Dièse, La, La_Dièse, Si);
	type Numéro_Gamme is range -4 .. + 4;
	type Rythme is range 10..240;
	Rythme_Courant : Rythme := 60;
	type Durée_Note is delta 1.0/16.0 range 0.00 .. 4.00;
	Noire : constant Durée_Note := 1.0;
	type Note_A_Jouer is
		record
			Note    : Gamme;
			Hauteur : Numéro_Gamme;
			Durée   : Durée_Note;
		end record;
	type Partition is array (Positive range <>)
		of Note_A_Jouer;
	procedure Jouer (Note : in Note_A_Jouer);
	procedure Jouer (Air  : in Partition);
	procedure Arrêter_Musique;
	function  Musique_En_Cours return Boolean;
end Musique;

Le corps de ce dernier paquetage ne fait appel qu'aux fonctionnalités du paquetage Contrôle_Son. Nous avons ainsi isolé et empaqueté les dépendances : en cas de portage sur une autre machine disposant d'instructions similaires, nous n'aurons à récrire que le paquetage In_Out. En cas de portage sur un système très différent, les deux couches inférieures devront être changées, mais le volume de réécriture sera en pratique extrêmement réduit. Bien sûr, l'application ne devra utiliser que le paquetage Musique, de façon à ne pas introduire de modifications importantes.

On imagine bien comment appliquer ce processus à d'autres exemples : une interface avec un système graphique aura une première couche représentant l'interface avec la bibliothèque système (appel de sous-programmes C en général), une deuxième couche fournira les fonctionnalités abstraites de base (positionnement à l'écran, dessins élémentaires) et la troisième couche fournira les éléments réellement utiles (menus, boîtes de dialogue, etc.).

Insistons sur le fait que pour les utilisateurs, seule la troisième couche est effectivement utilisable, et représente en fait le point d'entrée d'un sous-système local. Si on réalise plusieurs implémentations différentes, elles ne doivent différer ni sur la spécification syntaxique (sauf modification de partie private, ou peut-être extensions ne fournissant qu'une compatibilité à sens unique), ni sur la spécification sémantique, sauf pour les performances, le comportement vis-à-vis du parallélisme et des éléments non visibles extérieurement (comme l'utilisation ou non de la souris).

  1. Deux programmeurs effectuant alternativement des modifications sur un module pour l'adapter chacun à son besoin... et cassant ce qui vient d'être fait par l'autre.

Exercices

modifier
  1. Définir une interface en trois couches comme au paragraphe précédent pour gérer l'accès à une imprimante.
  2. Imaginer un cas de dépendances à l'initialisation qui requerrait l'utilisation du pragma Elaborate, mais ne pourrait être résolu avec le pragma Elaborate_All.
  3. Généraliser l'exemple du chapitre En guise d'introduction... en le passant en générique pour lui permettre de permuter les éléments de n'importe quel tableau monodimensionnel.

Établissement et gestion d'une bibliothèque de composants

modifier

Jusqu'à présent, nous avons vu comment développer des composants logiciels. Mais il ne s'agit là que d'une partie du problème : une fois que l'on possède des composants, que va-t-on en faire? À la limite, il est possible d'acheter des composants tout faits : peu importe alors comment ils ont été développés. Mais il faudra tout de même mettre en place une structure permettant de les retrouver et de promouvoir leur utilisation : c'est ce que nous allons étudier maintenant.

Responsable composants logiciels

modifier

L'utilisation de composants logiciels n'est rentable que s'ils sont réutilisés par plusieurs projets, qui peuvent avoir des intérêts divergents. Pour conserver la cohérence, il est nécessaire de mettre en place une structure transversale, dirigée par un responsable composants logiciels.

Le rôle du responsable composants logiciels n'est pas d'écrire de nouveaux composants. Si la taille de l'entreprise le permet, il pourra diriger une équipe entièrement consacrée aux composants, mais en général les composants seront développés par les équipes de projet. Le responsable composants logiciels est chargé de gérer les composants; il doit veiller au bon respect des règles d'uniformité, au suivi des différentes versions et de la documentation associée. Il lui faut maintenir une base de données des utilisations des composants, faire circuler les mises à jour des fiches descriptives, et aider les utilisateurs à rechercher le composant correspondant à leurs besoins.

Le responsable composants logiciels est également chargé de veiller à la promotion de l'utilisation de composants standard et d'empêcher la prolifération des adaptations «spécifiques» de ces composants. En particulier, il doit conserver les sources et refuser de fournir ceux d'un composant réutilisable à une équipe qui souhaiterait l'adapter à un cas particulier, si cette modification va dans le sens d'une perte de généralité.

Compte tenu de ses missions, la personne la plus apte à devenir responsable composants logiciels est un responsable qualité. Dans une période transitoire, ou si la quantité de composants ne justifie pas une personne à plein temps, la gestion des composants peut très bien s'effectuer sous le contrôle de la structure d'assurance qualité.

Documentation

modifier

La documentation est un point crucial pour le développement d'une approche «composant logiciel». Imagine-t-on les composants électroniques sans les nombreux guides, catalogues et autres data book permettant de connaître leurs caractéristiques et de retrouver la référence du ou des composants susceptibles de répondre à un besoin?

Il convient de distinguer tout de suite deux sortes de documentations : d'une part une documentation «utilisateur», équivalent des catalogues de matériel, destinée exclusivement aux utilisateurs des composants; d'autre part, une documentation de conception, de suivi et de maintenance, destinée aux concepteurs, gérants et mainteneurs du composant, et qui ressemble beaucoup plus à la documentation habituelle de tout projet logiciel. Nous allons examiner successivement ces deux aspects.

Documentation externe

modifier

Cette documentation s'adresse à l'utilisateur de composants logiciels. Le système le plus commode, éprouvé de longue date avec les composants matériels, consiste à établir un modèle de fiche préimprimée standard, décrivant les différents aspects du composant. Nous allons présenter un tel modèle de fiche adapté au cas des composants logiciels (le modèle complet est donné en annexe). Ce modèle ne doit pas être pris comme absolu, chaque entreprise est invitée à développer le sien propre en fonction de ses habitudes.

Nous avons identifié trois niveaux de documentation nécessaires à l'utilisateur pour choisir et utiliser un composant logiciel. À chacun de ces niveaux correspond une rubrique particulière de la fiche : «Identification», «Spécification» et «Implémentation». Des études aux États-Unis ont abouti à une conclusion quasi similaire, connue sous le nom de modèle «3c» : Concept, Contenu, Contexte [Tra89].

  1. Identification
  2. La fiche doit d'abord permettre à l'utilisateur de sélectionner rapidement un petit nombre de composants susceptibles de répondre à son besoin. Pour cela, il est nécessaire bien entendu que le composant réponde au besoin, mais également que le composant soit disponible pour le système de compilation, et même que les conditions de licence soient acceptables compte tenu des particularités du projet. La partie «identification» de la fiche regroupe donc une brève description du composant et des variantes disponibles, la classification du composant par rapport aux critères du chapitre 14, les modalités d'accès éventuelles (conditions de licence, droits d'accès) ainsi que les informations sur la disponibilité et l'historique du composant. Au vu de cette partie, l'utilisateur doit être à même de prendre l'une des décisions suivantes :
  • Utiliser le composant en l'état. C'est évidemment la décision préférentielle, sous réserve que le composant soit disponible pour le système de compilation désiré. La nécessité de modifier légèrement la structure du projet pour pouvoir utiliser le composant en l'état n'est pas une raison suffisante pour rejeter cette solution.
  • Porter le composant. Ceci correspond au cas où un composant paraît satisfaisant, mais n'est pas disponible sur le système de compilation désiré. Il convient de prendre immédiatement contact avec le responsable composants logiciels, 1) pour s'assurer que la non-disponibilité provient effectivement d'une absence de besoin antérieur et non d'une impossibilité technique cachée, 2) que le portage n'est pas déjà en cours par une autre équipe, et 3) pour demander éventuellement des crédits supplémentaires pour effectuer le portage. Si le portage est effectué par le demandeur, ses droits de modification sur le source seront extrêmement restreints, et toute modification allant au-delà de la simple adaptation au nouveau système de compilation doit être effectuée sous le contrôle du responsable composants logiciels.
  • Développer une nouvelle variante. Ceci correspond au cas où la spécification abstraite d'un composant paraît satisfaisante, mais où la variante correspondant aux besoins du projet n'existe pas encore. Les contraintes sont similaires au cas du portage du composant.
  • Demander une modification du composant. Ceci correspond au cas où un composant existant ne correspond qu'imparfaitement aux besoins, et où un perfectionnement semble possible, aboutissant à une nouvelle version du composant. Une demande en ce sens doit être déposée auprès du responsable composants logiciels, qui n'est recevable qu'à condition que la modification soit compatible de façon ascendante avec la version précédente et qu'elle aille dans le sens d'une plus grande généralité du composant. En aucun cas la modification ne doit être effectuée par celui qui est à l'origine de la demande, mais par l'équipe support du composant logiciel. Celle-ci proposera en retour une modification effective qui ne correspondra pas nécessairement à la demande originale. En effet, la demande peut révéler une insuffisance de conception d'un composant, mais l'analyse peut découvrir une possibilité de modification plus générale que ce qui était proposé initialement.
  • Proposer la définition d'un nouveau composant. Le responsable composants logiciels doit en être immédiatement informé, comme pour les propositions de modification. L'écriture d'un nouveau composant ne doit être acceptée que s'il est suffisamment différent des composants existant dans la base; sinon les autres possibilités doivent être envisagées en priorité. Le développement initial du nouveau composant peut être effectué par le demandeur, mais la définition des spécifications et l'intégration du composant doivent être effectuées sous le contrôle du responsable composants logiciels.
  • Spécification
  • Ayant identifié le bon composant, l'utilisateur doit connaître le mode d'emploi externe : c'est le but de la partie «Spécifications», qui reprend les éléments principaux de la spécification Ada de l'unité, en rajoutant les informations sémantiques qui ne peuvent être déduites de la seule spécification syntaxique. On séparera à ce niveau les éléments principaux, indépendants de la variante, des éléments annexes, particuliers à certaines variantes.
  • Implémentation
  • Enfin, l'utilisation effective nécessitera des informations complémentaires. C'est le rôle de la partie «implémentation», qui précisera également des éléments d'information n'ayant rien à voir avec la spécification abstraite, mais qui peuvent être importants pour l'utilisateur : utilisation d'éléments particuliers du langage (points flottants, parallélisme, gestion dynamique de mémoire), performances, conditions d'élaboration et d'initialisation...

    Documentation des performances

    modifier

    Bien que faisant logiquement partie de la documentation externe, la documentation des performances constitue un point suffisamment délicat pour que nous lui consacrions un paragraphe spécial. L'utilisateur d'un composant logiciel a besoin de connaître, au moins approximativement, les performances qu'il est en droit d'attendre d'un composant pour l'établissement d'un «budget de temps». Or les temps d'exécution peuvent être extrêmement variables pour un même module, parfois même en fonction de facteurs qui échappent totalement aussi bien au concepteur du composant qu'à l'utilisateur.

    Il existe une autre raison, généralement méconnue, et pourtant fondamentale, d'essayer de donner une idée des performances d'un module. L'utilisateur est en général obsédé par la question des performances, et si plusieurs structures utilisant différents composants sont possibles, il choisira bien souvent en fonction des performances des différents composants. En l'absence de documentation, même approximative, l'utilisateur devrait faire des mesures de performances avant de décider de la meilleure solution. Hélas! C'est rarement le cas. L'utilisateur choisit le composant qui lui semble le plus rapide, généralement sans aucun support objectif pour justifier son choix. Or s'il est un cas où l'on se trompe souvent, c'est sur l'évaluation des performances d'un module. Toute indication, même approximative et relative, des performances peut donc éviter de monumentales erreurs de conception.

    Un premier point peut être relativement facilement documenté : la complexité de l'algorithme, c'est-à-dire la variation des temps d'exécution en fonction de la taille des données manipulées. Ceci est indispensable pour les structures de données, mais d'autres composants peuvent être susceptibles de bénéficier de ce type d'indication. Cependant, cette indication ne fournit qu'une mesure relative, c'est-à-dire que connaissant le temps d'exécution du sous-programme sur une structure de taille N, elle permet d'évaluer le temps nécessaire pour une structure de taille 2xN. Ces éléments, relativement bien connus dans la littérature, doivent être mentionnés dans les différents cas de figure : pour une recherche, préciser s'il s'agit d'un temps moyen ou d'un temps maximal, les variations selon que la recherche aboutit ou échoue; pour un tri, mentionner si le fait que les données en entrée sont (presque) triées ou non a une influence sur les performances, etc. Ne pas oublier non plus les contraintes en espace mémoire.

    Reste le point sensible de la documentation des performances absolues. Nous ne connaissons pas de publications qui référencent ce problème, et les fabricants de composants «prêts à porter» semblent souvent discrets... Nous proposons la démarche suivante :

    • Définir un système de compilation de référence unique dans l'entreprise. Il s'agit d'une machine bien définie, avec les caractéristiques de modèle d'unité centrale, d'horloge, de mémoire...
    • Définir un ensemble de programmes de test standard. Cet ensemble de tests doit présenter un large panorama d'utilisation des fonctionnalités d'Ada. Se méfier des «benchmarks» connus (Whetstone, Dhrystone) qui ont généralement été adaptés à partir d'autres langages de programmation et ne sont donc généralement pas représentatifs du style de programmation Ada. Des suites d'évaluations spécifiques d'Ada (ACEC[1]...) peuvent être utilisées. Mesurer le temps d'exécution des tests standard sur le système standard.
    • Mesurer le temps d'exécution des tests standard sur le système de compilation du projet. Le rapport avec le temps d'exécution standard donnera une idée du rapport de puissance des machines. Il n'est nécessaire d'effectuer cette mesure qu'une fois pour chaque système de compilation.
    • Pour chaque composant, définir un programme de test standard en précisant bien les conditions, et documenter son temps d'exécution sur le système de compilation standard.

    On obtiendra une première idée des performances du module en multipliant le temps d'exécution du test standard par le rapport des puissances des machines. Ceci permet d'éliminer de façon quasi certaine des composants dont le temps d'exécution est beaucoup trop important, ou d'accepter sans trop de risques des modules dont le temps d'exécution est manifestement faible.

    Une meilleure idée des performances peut être obtenue en mesurant la vitesse d'exécution du test standard sur le système de compilation de l'utilisateur. Cette mesure est plus réaliste, car effectuée sur le système cible lui-même, donc en tenant compte des particularités du système de compilation, sans pour autant nécessiter d'écrire du code spécifique. Elle devrait permettre en particulier de choisir entre plusieurs composants de performances voisines.

    Si une grande précision est requise, notamment dans le cas de systèmes critiques à «budget de temps» serré, il faudra alors que l'utilisateur développe et mesure son propre jeu de tests représentatif de son application. Noter que l'adaptation du test standard peut faciliter l'écriture de ces tests qui doivent alors être caractéristiques de l'utilisation faite du composant par le logiciel.

    Ce système n'a pas la prétention de résoudre totalement le problème de la documentation des performances, et il est important de bien en comprendre les limites. Tout d'abord, le rapport des puissances des machines n'est pas uniforme, et peut varier pour une même machine selon les compilateurs : tel système, particulièrement performant pour la commutation des tâches, peut avoir des performances déplorables sur la génération de code des agrégats tableau! Le profil, le style d'utilisation du langage peuvent avoir une influence sur l'efficacité relative des systèmes. C'est pourquoi le programme de test standard doit offrir un large éventail d'utilisation des fonctionnalités d'Ada. Ensuite, des phénomènes liés au matériel peuvent mettre en défaut l'hypothèse de linéarité. Dans le cas d'un logiciel qui boucle sur une structure de données qui tient entièrement en mémoire cache, la première boucle peut être plusieurs ordres de grandeur plus lente que les suivantes. Si la structure se met à déborder de la mémoire cache, les performances peuvent s'effondrer brusquement. Quant aux processeurs vectoriels, les surprises peuvent être encore plus importantes, selon qu'ils sont capables de «chaîner» ou non...

    En résumé, la démarche que nous proposons ne dispense pas de l'écriture de tests spécifiques dans les cas critiques; mais elle devrait permettre d'obtenir une base de décision plus objective dans les cas où l'écriture de tests spécifiques ne se justifie pas. Notons que l'essentiel du travail sera effectué par le responsable composants logiciels, de façon à fournir aux décideurs un maximum d'informations pertinentes sans leur imposer de coût supplémentaire.

    1. Ada Compiler Evaluation Capability

    Exemple d'utilisation

    modifier

    Il est bon de fournir un programme d'exemple associé à chaque composant logiciel, montrant la «bonne» façon de l'utiliser. Attention : celui-ci doit être différent du programme de test. Ce dernier doit servir à mesurer les performances, alors que l'exemple sert de modèle pour les utilisateurs potentiels.

    Documentation interne

    modifier

    La documentation interne n'est destinée à être lue que par le responsable composants logiciels et son équipe. Elle est proche de la documentation interne de tout développement logiciel en ce qui concerne les documents de conception. Plus que partout ailleurs, il importe de mentionner clairement les décisions de conception qui ont été prises, et les différents compromis possibles avec les raisons des choix qui ont conduit à la solution finalement adoptée. Nous avons vu sur des exemples que les raisons des choix sont souvent complexes, et il faut éviter que des personnes nouvellement arrivées réessayent des solutions qui avaient été envisagées au début, puis abandonnées.

    Ces documents doivent être complétés par une structure appropriée permettant de suivre et de contrôler les utilisations des différents composants.

    Administration de la base de composants

    modifier

    Validation de composants

    modifier

    Tout composant doit être muni d'une batterie de tests destinés à vérifier son comportement, que nous appellerons sa suite de validation. Un seul test peut être communiqué aux utilisateurs : le test de performance «standard».

    Les autres tests, gérés par le responsable composants logiciels, sont essentiellement destinés à vérifier le bon comportement du composant. L'assurance de non-régression en cas de nouvelle version est encore plus importante pour des composants logiciels que pour des logiciels fermés : en cas d'introduction d'une erreur, tous les projets utilisateurs risquent d'être affectés! Tout rapport d'anomalie concernant un composant doit conduire à l'incorporation dans la suite de validation du test qui aurait dû diagnostiquer l'erreur, et un test ne doit jamais être retiré de la suite. En pratique, il est nécessaire d'associer à chaque composant une procédure automatisée (fichier batch) lançant tous les tests correspondants. La suite de validation doit obligatoirement être passée entièrement avant d'accepter une nouvelle version d'un composant.

    Gestion de configuration et suivi d'utilisation

    modifier

    L'utilisation de composants implique que l'on soit capable, à partir du numéro de version d'un programme livré au client, de connaître exactement la version de chacun des composants logiciels utilisés. Il s'agit donc typiquement d'un problème de gestion de configuration, et de nombreux outils sont disponibles pour effectuer cette tâche.

    Mais le partage de ressources qu'entraîne l'utilisation de composants logiciels pose le problème inverse. Si une erreur est diagnostiquée et corrigée dans un composant, cela affectera non seulement le programme qui aura détecté l'erreur, mais aussi tous les autres programmes utilisateurs du composant. Il faut donc être capable de retrouver les systèmes utilisateurs d'un composant donné. Cet aspect n'est pas toujours pris en compte par les systèmes de gestion de configuration, et nécessite une politique propre. Elle exclut en particulier de permettre aux utilisateurs d'employer «librement» une bibliothèque de composants, car on perdrait alors la trace des utilisations. Il faut donc mettre en place une politique de droits d'accès obligeant les utilisateurs à demander au responsable composants logiciels la mise à disposition de modules, et donc à se déclarer explicitement à lui. Plus difficile à obtenir (et à vérifier) : si un composant a été demandé, mais n'est finalement pas utilisé dans un système, il faut retirer le demandeur de la base des utilisateurs.

    Une solution alternative serait d'obliger en fin de projet les développeurs à faire une liste des composants utilisés. Noter qu'encore une fois, cette pratique est courante dans les composants matériels : après avoir réalisé une carte électronique, l'ingénieur récapitule les éléments utilisés pour permettre la fabrication. Le problème est que cette liste n'est pas nécessaire pour la «fabrication» en série du logiciel, et risque donc d'être plus difficile à obtenir et à vérifier. On peut imaginer qu'à la fin d'un projet, le responsable effectue une recompilation finale dans une bibliothèque «vierge» dans laquelle on n'aurait mis que les composants effectivement déclarés par les concepteurs.

    Support des environnements de programmation

    modifier

    La mise en œuvre des composants logiciels sera plus ou moins facile et nécessitera différents niveaux de contrôles manuels selon les fonctionnalités fournies par l'environnement de programmation. Un élément que l'on peut avantageusement mettre à profit est la notion de sous-bibliothèque. La plupart des environnements proposent plus ou moins cette fonctionnalité permettant de séparer une bibliothèque logique en plusieurs bibliothèques physiques. Selon les cas, il s'agira de simples liens entre bibliothèques ou de bibliothèques imbriquées avec des règles de visibilité sur les unités compilées similaires à celles des langages à structure de blocs.

     
    Figure 31 : Sous-bibliothèques
    Figure 31 : Sous-bibliothèques

    Par exemple, on peut utiliser une structure de bibliothèque imbriquée pour gérer différentes variantes dépendant de la cible, comme dans la figure 31. La bibliothèque principale contient les spécifications communes aux différentes variantes, ainsi éventuellement qu'une implémentation portable. Des sous-bibliothèques contiennent des formes différentes d'implémentation pour des cibles particulières. En compilant depuis la sous-bibliothèque appropriée, on récupère la version la plus efficace selon la cible. Suivant le même principe, on pourrait gérer différentes versions des composants. Ce sera au responsable composants logiciels de déterminer la structure la plus appropriée, compte tenu des possibilités de l'environnement, du nombre de composants et de leurs variantes, et des besoins des projets. On ne peut établir de règle générale, si ce n'est qu'il faut utiliser toutes les possibilités de l'environnement.

    Ce genre de possibilité a été extrêmement étendue dans certains environnements (Rational [Ler90]) : à chaque projet est associée une vue spécifiant les versions et les familles de chaque composant. La notion de vue permet alors de sélectionner automatiquement l'unité adaptée à chaque projet.

    Évolution des composants

    modifier

    Comme tout logiciel, les composants sont amenés à évoluer dans le temps. Compte tenu de l'impact d'une modification de composant (surtout si la spécification en est affectée), il convient de maîtriser soigneusement cette évolution. Notons que dans leur enfance, les composants tendent à évoluer fortement : les besoins sont rarement bien définis au début, et ce n'est qu'avec l'expérience que l'on parvient à définir la «bonne» forme d'un composant. Avec le temps, ils tendent à se stabiliser. Une fois qu'un composant a été réutilisé plusieurs fois, en particulier s'il a pu être repris sans modification par des applications très différentes, il convient de le considérer comme figé et d'accueillir avec beaucoup de méfiance toute demande de modification.

    1. Évolutions compatibles
    2. Ce type d'évolution ne remet pas en cause les applications existantes; tout au plus risque-t-on des recompilations plus ou moins importantes, selon la chaîne des dépendances et les facilités de l'environnement de programmation. Le cas le plus fréquent est l'ajout de fonctionnalités : on rajoute des éléments dans une spécification de paquetage. Ceci peut créer des erreurs de compilation dans les modules plus anciens, si les nouveaux noms entrent en conflit avec des noms existants : cela doit attirer l'attention du responsable, car un conflit de nom peut révéler une duplication d'abstractions. Des besoins nouveaux peuvent conduire à l'ajout de paramètres à une spécification de sous-programme. On maintiendra la compatibilité en utilisant une valeur par défaut qui corresponde à l'ancienne utilisation. Par exemple, si l'on disposait d'une fonction d'effacement d'écran :
      procedure Effacer_Ecran;
      

      et que l'on souhaite pouvoir choisir la couleur de l'écran à l'effacement, on peut modifier la spécification ainsi :

      procedure Effacer_Ecran (En_Couleur : Couleurs := Noir);
      

      Enfin, toute modification portant sur le corps des unités sans changer la sémantique (en particulier les améliorations d'efficacité) sera compatible.

    3. Évolutions incompatibles
    4. Des modifications plus importantes de la spécification pourront entraîner des incompatibilités nécessitant la modification des programmes utilisateurs. Plus dangereux : des modifications des corps qui changent la sémantique de l'unité peuvent entraîner des incompatibilités d'exécution non détectées à la compilation. On évitera ceci en provoquant systématiquement une modification de la spécification (changement de nom...) lors d'une modification incompatible du corps. Toute tentative de recompilation provoquera des erreurs, qui préviendront les utilisateurs de la modification du comportement. Il importe donc d'avertir les utilisateurs qu'une erreur de compilation provenant d'une modification d'un composant doit les inciter à se renseigner sur la cause de l'évolution, au lieu de modifier leur code «pour que ça passe». Noter que plus les possibilités du typage Ada auront été soigneusement utilisées pour modéliser l'abstraction considérée, plus il est vraisemblable qu'une modification sémantique entraînera des modifications syntaxiques, donc des incompatibilités. Encore une fois, ces incompatibilités ne doivent pas être évitées, car elles procurent un degré de sécurité supplémentaire.

    Recherche de composants

    modifier

    La recherche de composants logiciels s'apparente à la recherche documentaire... et souffre des mêmes difficultés. Il importe de fournir au développeur un moyen d'investigation puissant, sélectif mais pas trop, de la base de composants. Si une recherche n'est pas assez sélective, le nombre de composants identifié est trop grand et risque de décourager l'utilisateur; une recherche trop sélective dépendra trop de la formulation de la demande, et courra le risque de ne pas identifier le composant alors même qu'il existe.

    À la base de tout système de recherche de composants se trouve le problème de la classification. Selon [Pri91], un schéma de classification des composants logiciels doit répondre aux points suivants :

    • Il doit pouvoir gérer un nombre toujours croissant de composants.
    • Il doit permettre de retrouver des composants similaires, ne correspondant pas exactement à la demande.
    • Il doit permettre de retrouver des fonctionnalités équivalentes pour des domaines différents.
    • Il doit être très précis et puissamment descriptif.
    • Il doit être facile à maintenir; la mise à jour et la redéfinition du vocabulaire ne doivent pas entraîner de reclassification totale.
    • Il doit être facile à utiliser par le responsable comme par l'utilisateur.
    • Il doit être possible à automatiser.

    Selon le rapport du groupe de l'ACM qui étudie ce problème [Sol91], différents systèmes ont été mis en œuvre pour la recherche de composants :

    • Utilisation du système de hiérarchie de fichiers. Des répertoires sont dédiés aux grands thèmes, des sous-répertoires aux sous-thèmes, des sous-sous-répertoires aux variantes d'implémentation. Cette solution, utilisée dans la PAL (Public Ada Library, bibliothèque de composants publics), permet d'obtenir une certaine organisation en l'absence d'outils spécifiques. Elle ne représente évidemment qu'un pis-aller.
    • Système de recherche par mots clés et index. Cette technique est directement inspirée de la recherche documentaire dont elle peut reprendre les outils. Le système SAIDA de CISI-Ingénierie appartient à cette catégorie. La difficulté réside surtout dans la définition des mots clés : l'idée que se fait l'utilisateur potentiel d'un composant ne correspond pas forcément à la présentation qu'en a fait le concepteur, ce qui entraîne le risque d'ensembles de mots clés disjoints, donc d'échec de la recherche alors même que le composant est disponible.
    • Systèmes à facettes. Il s'agit d'une recherche multicritères, selon des points de vue (facettes) orthogonaux. On a ainsi une description de l'élément recherché selon les divers modes de classification que nous avons étudiés. Cette méthode, qui correspond le mieux aux critères ci-dessus, a été utilisée aux États-Unis dans le cadre du projet STARS et du catalogue RAPID.
    • Systèmes de base de connaissance. Il s'agit de véritables systèmes experts fondés sur des réseaux sémantiques. La bibliothèque de réutilisation de Unisys utilise ce principe.
    • Hypertexte, utilisé par Westinghouse.

    Ces systèmes sont souvent expérimentaux, et la question de l'utilité de l'outil par rapport aux résultats (marteau-pilon pour écraser une mouche!) reste ouverte. Certains (comme Rose-Ada [Moi91]) visent également la gestion des éléments réutilisables autres que les modules Ada (conceptions, documentation). En l'absence d'outils exclusivement dédiés aux composants, la meilleure solution est d'utiliser conjointement plusieurs techniques:

    • Un système d'indexation par thèmes, mots clés, etc. Un système de gestion documentaire est parfaitement apte à jouer ce rôle. Une organisation hiérarchique peut suppléer ce système, surtout au début.
    • Une documentation papier, de type data book, régulièrement mise à jour et largement disséminée dans l'entreprise.
    • Un responsable composants logiciels, connaissant très bien sa base de données et disponible pour conseiller les utilisateurs.

    Seules l'expérience pratique et la mise à disposition de nouveaux outils permettraient de perfectionner ce premier dispositif.

    Analyses de réutilisabilité

    modifier

    Développer une approche de conception fondée sur les composants logiciels nécessite d'intégrer des phases d'analyse de réutilisabilité en plus des étapes classiques. En fait, deux phases bien distinctes sont nécessaires : au début et à la fin du développement.

    Analyse à priori

    modifier

    La phase d'analyse de réutilisabilité a priori a pour but de déterminer les composants disponibles utilisables par le projet. La première condition pour obtenir une bonne efficacité de cette phase est la bonne connaissance par les équipes de développement des composants disponibles. Le rôle du responsable composants logiciels est à cet égard déterminant.

    La deuxième condition est un changement d'état d'esprit de l'équipe de programmation : il faut passer du «je réutilise si par hasard il y un composant qui convient» au «je n'écris du code nouveau que s'il n'y a pas moyen de faire autrement». Le programmeur doit voir son code comme une «colle» destinée à faire fonctionner des composants, et non comme un ensemble dont il maîtrise tous les niveaux d'abstraction. La formation joue donc un rôle prédominant pour l'évolution de l'état d'esprit.

    En fait, l'analyse de réutilisabilité peut s'effectuer à deux niveaux. Lors des choix initiaux, les décisions de conception sont guidées par la disponibilité des composants. Il s'agit d'une influence globale des composants sur la structure du projet. En particulier, c'est à ce niveau que s'effectue le choix d'une «famille» dont l'utilisation sera imposée à tout le projet. Ensuite, lorsque l'on identifie un objet lors de la descente dans les niveaux d'abstraction, il faut chercher dans la bibliothèque si l'on dispose déjà de l'abstraction nécessaire, ou d'une abstraction suffisamment voisine, qui éviterait un développement à partir de rien. Attention : il ne s'agit aucunement de prôner une démarche ascendante de la conception de logiciel. Ce que nous disons ici, c'est qu'il faut orienter la démarche descendante pour lui permettre d'«atterrir» sur des composants existants plutôt que sur de nouveaux développements. Le mieux pour comprendre cette démarche est de reprendre l'analogie avec l'électronique.

    Si l'on doit concevoir une carte logique complexe, on la décompose en unités logiques, elles-mêmes composées de sous-ensembles, puis de circuits. Il s'agit bien d'une analyse descendante. Il n'empêche qu'une connaissance des composants de base est nécessaire dès le début: on fait par exemple le choix d'utiliser une «famille» TTL, ce qui aura une influence sur les spécifications de l'alimentation électrique. De même, la structure globale est influencée par le fait qu'au bout du compte, les composants élémentaires seront des portes logiques. Une fois arrivé au niveau des composants, l'électronicien fera son possible pour utiliser des composants existants même s'ils ne correspondent pas exactement à ses besoins. Si par exemple il a besoin de la porte indiquée à la figure 32(a) (porte AND inversant une des entrées, non disponible directement), plutôt que de commencer à fondre du silicium pour réaliser un composant spécifique, il préférera la réaliser comme indiqué à la figure 32(b) (une porte AND et un inverseur) ou même, solution moins évidente, mais utilisant une porte NOR plus fréquente qu'une AND, comme à la figure 32(c).

     
    Figure 32: Un composant électronique non standard
    Figure 32: Un composant électronique non standard

    Analyse à posteriori

    modifier

    La phase d'analyse de réutilisabilité a posteriori a pour but de déterminer dans un projet terminé les modules susceptibles d'être récupérés et intégrés à la base de composants logiciels. L'identification de tels modules n'est pas évidente, d'autant plus qu'ils auront été développés dans un contexte particulier, et ne se présentent donc pas encore sous forme de composants réutilisables.

     
    Figure 33: Topologie de projet
    Figure 33: Topologie de projet

    Un bon moyen de les identifier est d'analyser la topologie du projet, c'est-à-dire le graphe des clauses with. Cette analyse est grandement facilitée si l'on dispose d'un outil graphique adéquat. Le graphe est en général très complexe, d'autant plus que les outils de représentation graphique des unités Ada se contentent souvent de tirer des flèches entre des boîtes sans souci de l'architecture sous-jacente. Il convient donc d'organiser la représentation graphique. La figure 33 représente une organisation (simplifiée) caractéristique d'un projet. Il est difficile a priori de reconnaître une structure dans un tel graphe. Cependant, on constate la présence d'un certain nombre de modules (comme X et Y) qui sont utilisés un peu partout dans le projet, sans aucun rapport avec les différents niveaux d'abstraction. Ces composants seront habituellement des abstractions générales (chaînes, bibliothèques mathématiques), pouvant appartenir au domaine de problème particulier du logiciel, mais ayant une nature «fondamentale». Nous appellerons de tels modules des «bus logiciels», par analogie avec les bus d'alimentation des cartes électroniques qui alimentent tous les circuits, indépendamment de leur structure logique. De même, ces bus logiciels se caractérisent par le fait qu'ils sont utilisés dans tout le projet, indépendamment du découpage en niveaux d'abstraction.

     
    Figure 34: Topologie de programme réordonnée
    Figure 34: Topologie de programme réordonnée

    La représentation du graphe se simplifie grandement si, comme sur la figure 34, on représente effectivement l'utilisation de tels composants comme un «bus» sur lequel les autres composants viennent s'alimenter. De tels composants sont proches de la notion d'«objet d'environnement» que l'on trouve dans HOOD. S'ils ont été développés spécifiquement par le projet, ils sont de très bons candidats à la réutilisation. Noter que les composants d'une «famille» se reconnaissent aisément sur une telle représentation, puisqu'ils sont tous connectés sur le même bus. Inversement, le programme principal dépend d'un petit nombre de modules (hors composants bus), qui correspondent au premier niveau d'abstraction de la décomposition (A,B). Ces modules sont en général spécifiques de l'application et ont peu de chances d'être réutilisables.

    Ces modules premiers utilisent des modules (S1, S2) qui se comportent comme des points d'entrée de sous-arborescences qui ne sont pas utilisés par des modules d'une autre sous-arborescence ; il s'agit là typiquement de sous-systèmes. Il est difficile de généraliser à ce niveau, mais ces sous-systèmes peuvent ou non être candidats à la réutilisation. S'ils sont réutilisables, ils constituent en général des abstractions de haut niveau. En tout état de cause, l'analyse de réutilisabilité doit porter sur le seul module d'entrée du sous-système. On remarque qu'un composant bus peut être lui-même en fait un sous-système local (S3), et qu'il peut exister des dépendances entre bus (X vers Y).

    À partir de cette représentation, il est possible d'identifier un nombre pas trop important de candidats à la réutilisation. Il faut inspecter ces modules pour déterminer s'ils correspondent bien à des abstractions clairement identifiées d'objets du monde réel et si ces abstractions ont un sens en dehors du projet qui les a développées. Si la réponse à ces deux questions est positive, il faut transformer le module en composant réutilisable. Le module de départ est souvent incomplet: le projet n'a développé que les fonctionnalités dont il avait besoin, alors que l'abstraction devrait logiquement en fournir d'autres. Le code doit être retravaillé comme nous l'avons vu pour parfaire la définition sémantique et le rendre plus robuste; on parle de durcissement du composant. Ensuite, il faut étudier si la réutilisabilité du composant peut être accrue en le rendant générique. Il y a là un travail d'industrialisation tout à fait spécifique. Une fois le composant ainsi modifié, il risque d'être devenu incompatible avec le projet dont il est issu ! À ce point, il est en général trop tard pour faire les adaptations qui permettraient au logiciel d'utiliser la forme industrialisée du composant. Il faut conserver trace de cet état de fait et profiter de la prochaine révision du logiciel pour le réaligner sur le composant standard.

    Exercices

    modifier
    1. Étudier comment organiser une base de composants logiciels destinés à l'enseignement. Adapter les conseils donnés dans ce chapitre au contexte particulier du milieu universitaire.
    2. Tracer le graphe de dépendances d'un projet existant et le réordonner avec des bus logiciels.
    3. Établir les fiches descriptives, selon le modèle donné en annexe, des paquetages prédéfinis faisant partie de l'environnement standard Ada. Pour les parties dépendant de l'implémentation, se référer aux paquetages fournis avec le GNAT.

    Avantages et difficultés d'une politique de réutilisation

    modifier

    Avantages

    modifier

    Nous avons essayé tout au long de cette partie d'exposer les tenants et les aboutissants, les difficultés, les risques et les contraintes d'une approche du développement employant des composants logiciels réutilisables, et de montrer comment Ada fournit des outils de nature à simplifier cette tâche. Arrivé à ce point, le lecteur peut être saisi d'une crainte: tous ces efforts valent-ils réellement la peine? La réponse est un oui franc et massif, si l'on considère non le seul coût, mais ce que l'on obtient par rapport à ce qui a été dépensé.

    Une fois la structure mise en place et les composants disponibles, on peut considérer que 50 à 75 % du code «brut» d'un programme peuvent être obtenus à partir de composants réutilisés[1]. La raison en est que les aspects de présentation et d'interface utilisateur deviennent de plus en plus importants, et sont d'énormes consommateurs de code. Ajoutez à cela la gestion des structures de données, méthodes d'accès et manipulations courantes, et vous vous apercevrez que la partie réellement «noble» et nouvelle d'un projet logiciel est loin de représenter la majorité du code.

    Non seulement le code réutilisé n'est plus à écrire, mais il a pu être développé avec plus de soins, puisque l'effort nécessaire pour le supplément de qualité est amorti sur plusieurs projets. Combien de projets nécessitant un petit tri pas vraiment critique utilisent-ils encore le quick-sort, qui n'a de «quick» que le temps pour l'écrire? Un tri générique utilisera un algorithme de tri tournoi, notablement plus compliqué à programmer, mais tellement plus efficace. Quelle importance, puisque précisément on ne le récrit pas à chaque fois? De plus, les modules ayant été utilisés, donc testés, dans de nombreuses configurations, le risque d'erreurs résiduelles est considérablement amoindri, pour ne pas dire supprimé à partir d'une dizaine d'utilisations. Enfin, en cas de problème, la maintenance et la réparation sont centralisées, et la correction bénéficiera automatiquement à toutes les applications utilisatrices[2].

    Encore les remarques précédentes ne s'appliquent-elles principalement qu'aux composants développés par l'entreprise. Mais de nombreux composants sont disponibles aujourd'hui commercialement, pour un prix certes parfois élevé, mais faible devant ce qu'il faudrait investir pour développer soi-même l'équivalent. La présence de ces composants peut même conditionner la faisabilité d'un projet: il ne serait pas raisonnable de développer un projet sous XWindow si l'on devait récrire toute l'interface.

    1. Estimation personnelle.
    2. À condition bien entendu qu'il s'agisse de réutilisation «telle quelle», et non d'une recopie avec modification d'un module dans le programme.

    Difficultés

    modifier

    Réticences psychologiques

    modifier

    La première, et peut-être la plus grande difficulté à l'introduction d'une politique de réutilisation, n'est pas d'ordre technique, mais psychologique: le syndrome NIH (Not Invented Here). Le programmeur tend à se méfier de tout composant acheté à l'extérieur. Est-il aussi bien que ce qu'il aurait réalisé lui-même ? Même une bonne documentation peut ne pas être suffisante pour entraîner la confiance [Bau91]. S'il ne lui viendrait pas à l'idée de récrire une bibliothèque mathématique (parce qu'il ne s'en sent pas capable), acheter des composants à l'extérieur lui paraît gaspiller de l'argent... même si ceux-ci coûtent considérablement moins cher que le temps qu'il lui faudrait pour les développer. Parfois, la récriture peut être un prétexte pour utiliser un autre langage [Bau91]. La nécessaire discipline consistant à adapter la conception aux composants existants plutôt que l'inverse est une démarche nouvelle, peu répandue dans le domaine du logiciel. Enfin, l'approche fonctionnelle descendante permet mal l'identification de composants réutilisables; une démarche objet, qui est encore loin d'être universellement acceptée, est indispensable pour permettre d'identifier ces composants.

    Du côté positif, on note que les craintes qu'ont les programmeurs à réutiliser, qui relèvent beaucoup du fantasme, se dissipent lors de l'utilisation effective de composants de haute qualité. On peut même assister à un retournement psychologique: le programmeur peut avoir l'impression d'être en charge de la partie «noble» du projet, sans avoir à se préoccuper de l'«intendance». Lorsqu'une politique globale de réutilisation est effectivement mise en place, on voit l'apparition d'un effet majoritaire [Car91]: puisque tout le monde utilise les composants, les nouveaux arrivants sont beaucoup plus enclins à suivre la pratique générale. [Fav91] note cependant qu'il peut y avoir une déception lorsque la mise en œuvre des composants n'est pas très facile, avec un risque de rejet en bloc. Formation, valorisation et information forment donc la base de la préparation à l'introduction d'une démarche «composants»... avec la mise à disposition d'une bibliothèque de base de qualité.

    Inversement, il faut veiller à ne pas dévaloriser non plus l'écriture de composants. Comme nous l'avons vu, cette écriture relève d'une spécialité, nécessitant une connaissance approfondie du langage et une excellente faculté d'abstraction. Il convient donc d'identifier les concepteurs de composants comme des spécialistes à part, indépendants des développeurs d'application. Ils n'ont pas à connaître les éléments du domaine de problème d'un développement particulier, mais doivent être capables au contraire de transcender les cas particuliers pour en tirer des éléments généraux. Développeurs de composants et développeurs d'application doivent ainsi s'estimer réciproquement comme des spécialistes compétents dans des domaines différents, dont les uns comme les autres sont indispensables à la bonne fin des projets.

    Problèmes structurels de l'entreprise

    modifier

    Si les problèmes de personnes sont une facette importante de la mise en œuvre d'une politique de réutilisation, ils ne sont pas les seuls. C'est toute la hiérarchie de l'équipe de développement qui se trouve mise en cause. Le rôle du responsable composants doit être bien compris, en particulier par rapport au responsable qualité: comme lui, il apparaîtra transversal aux différents développements, mais plus directement responsable de modules codés. Le responsable qualité en revanche devra veiller à l'utilisation effective de composants réutilisables de préférence à des développements nouveaux; il sera également responsable de la qualité des composants, mais non de leur écriture. L'équipe «composants» apparaît donc comme une sorte de projet permanent, transversal et délocalisé par rapport aux développements spécifiques.

    Il faut établir de nouveaux circuits de communication: l'équipe composants doit être capable de conseiller rapidement le concepteur et de fournir une documentation à jour des éléments disponibles. Si l'accès aux composants est lent ou difficile, le concepteur risque d'être rapidement découragé.

    Enfin, l'utilisation des composants est très directement liée à une méthode de conception orientée objet. Il importe donc d'accompagner l'introduction des composants par des cours de méthodologie appropriés et par une sensibilisation à la rentabilité que la réutilisation peut procurer.

    Outillage

    modifier

    Aux besoins spécifiques de la réutilisation doivent répondre des outils spécialisés. Force est de reconnaître que l'on ne dispose actuellement que de peu d'outils spécifiques. Les gestionnaires de configuration ont été conçus dans une optique «projet» plutôt que «composants». Les systèmes d'archivage et de recherche qui prennent en compte les besoins particuliers des composants sont encore rares.

    Les différents responsables devront donc utiliser des outils (systèmes d'archivage, gestionnaires de documents, ...) qui ne sont pas forcément parfaitement adaptés. Ceci ne signifie pas bien sûr qu'il ne faille pas utiliser d'outils, mais simplement que leur mise en œuvre dans un contexte de composants logiciels risque d'être moins aisée et moins adaptée que dans d'autres contextes. Ce manque d'outils risque donc de venir compliquer la tâche de ceux qui sont chargés de mettre en place une politique de réutilisation.

    Mais avec la multiplication des composants commerciaux, le besoin d'outils devient de plus en plus explicite. Des projets comme STARS aux États-Unis ont commencé à s'attaquer au problème. Divers systèmes commerciaux commencent à voir le jour, et on peut espérer que ce problème s'atténuera dans un avenir relativement proche.

    Conclusion

    modifier

    La réussite d'une politique de promotion de composants logiciels est un processus complexe qui nécessite la mise en œuvre conjointe de nombreux éléments. Comme le rappelait Jeffrey Sutherland (cité dans [Rem94]):

    Cinq conditions sont nécessaires: spécifier l'unité de réutilisation (composant logiciel) et l'encapsuler; associer conception, documentation et code, et en maintenir la cohérence; indexer les composants et les stocker dans un référentiel objet; former à la réutilisation; enfin préparer l'organisation à l'appliquer.

    L'utilisation d'un langage de programmation adapté comme Ada est un élément nécessaire, indispensable même, mais non suffisant. Il faut aussi réorganiser la structure des équipes de développement pour intégrer l'approche «composants» de façon globale. La présence d'un responsable spécialisé pour introduire le processus de réutilisation, mettre en place une structure appropriée et aider les développeurs est nécessaire. Les facteurs psychologiques ne doivent pas être négligés, et une formation aux techniques spécifiques de la réutilisation, en particulier à l'approche objet, est indispensable.

    La documentation et la recherche des composants forment une part importante du succès ; reprenons une citation d'un rapport de la NASA sur la réutilisabilité :

    Il faut qu'il soit plus facile de trouver, d'identifier et de comprendre un composant que de le construire à partir de rien.

    Cette partie du livre ne s'est intéressée qu'à l'aspect «composants» de la réutilisation, mais la réutilisation en général couvre d'autres concepts: réutilisation de spécifications, de conceptions, de documentation... De gros efforts sont entrepris actuellement dans le domaine des environnements de développement, qui tentent d'intégrer dans une structure commune toutes les formes de réutilisation.

    La démarche par composants logiciels est une évolution nécessaire (mais, encore une fois, non suffisante) pour le développement de logiciels fiables de grande taille. Il ne faut s'en cacher ni le coût, ni l'impact sur l'organisation même du développement de logiciel ; il s'agit d'un investissement réel, mais d'un investissement rentable.