Composants logiciels
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 !
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...=
É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 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 ?
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 codice_1 et codice_2 permet de remédier aisément à ce problème.
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 :
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 codice_3 déjà égale à codice_4, et lèvera donc l'exception.
Il faut faire un cas particulier si la borne supérieure de codice_5 est codice_4.
Et comme l'expression codice_7 apparaît également, il faut aussi faire un cas particulier si Coupure prend la valeur codice_4...
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 codice_4 »...
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. 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 codice_10, 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 codice_11 ! 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
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'est-ce qu'un composant logiciel ?=
Définition.
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. 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, 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.
Contraintes supplémentaires pour les composants logiciels.
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.
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:
Exercices.
=Organisation et classifications des composants logiciels=
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.
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.
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:
À 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.
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:
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.
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:
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).
Parcours.
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.
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.
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.
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.
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.
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. 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.
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.
Composants liés.
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: définir une nouvelle entité indépendante, définir un type englobant ou enrichir les fonctionnalités.
Classification des spécifications et des implémentations.
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.
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 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».
Exercices.
=Règles pour l'écriture des composants logiciels=
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.
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 :
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 :
Gestion des situations exceptionnelles.
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 :
Initialisation.
Initialisation du composant.
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
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).
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 :
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.
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 codice_13 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.
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. 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 codice_13 dans tout module déclarant des objets de ce type.
Définition de génériques.
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.
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;
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.
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.
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, 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).
Portabilités et dépendances à l'implémentation.
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.
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.
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. 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.
Dépendances à l'implémentation.
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» 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é, 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).
Exercices.
=Établissement et gestion d'une bibliothèque de composants=
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.
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.
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.
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].
Documentation des performances.
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 :
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.
Exemple d'utilisation.
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.
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.
Validation de composants.
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.
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.
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
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.
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.
Recherche de composants.
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 :
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 :
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:
Seules l'expérience pratique et la mise à disposition de nouveaux outils permettraient de perfectionner ce premier dispositif.
Analyses de réutilisabilité.
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.
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
Analyse à posteriori.
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
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
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.
=Avantages et difficultés d'une politique de réutilisation=
Avantages.
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. 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.
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.
Difficultés.
Réticences psychologiques.
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.
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.
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.
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]):
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é :
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.