Organisation de projet et choix fondamentaux
Conduire un projet, ce n'est pas seulement adopter une méthode de conception et écrire du code. Un développement s'effectue en équipe, et même en utilisant une méthode rigoureuse, des choix fondamentaux dont dépendra le succès du futur produit doivent être effectués, dès avant le démarrage du projet et jusqu'aux dernières étapes de la réalisation.
En fait, il faut être convaincu que la solution à un problème informatique n'est jamais unique, et que toute décision a des avantages et des inconvénients. L'informaticien doit donc être avant tout une personne capable d'arbitrer des compromis. Hélas, trop souvent il se précipite sur la première idée qu'il trouve au lieu d'évaluer les différentes possibilités. Souvent, il ne se rend même pas compte qu'il a fait un choix, tant la solution adoptée lui paraît «évidente».
Dans cette partie, nous allons donc expliquer l'organisation générale d'une équipe de programmation, puis détailler les principaux choix intervenant à différents niveaux du développement, afin de faire prendre conscience de tous les points qu'il importe d'étudier soigneusement avant de se lancer sur son clavier!
=L'équipe de développement=
Les rôles à remplir.
La réalisation d'un projet logiciel quelque peu important ne peut se faire qu'en équipe. Trop souvent, celle-ci ne comporte qu'un chef de projet (qui met parfois la main à la pâte) et des développeurs qui font la conception, le codage et la mise au point. En fait, dans toute équipe de développement, il existe un certain nombre de rôles qui doivent être remplis. Bien qu'ils puissent varier légèrement, on trouve généralement:
La taille de l'équipe de développement ne permettra pas en général d'affecter une personne différente à chacun de ces rôles. Il n'empêche que chacun d'entre eux doit être rempli par quelqu'un; plusieurs rôles peuvent donc être dévolus à la même personne.
Affectation des différents rôles.
Le chef de projet est généralement choisi par des instances supérieures, ainsi qu'un ensemble de personnes chargées de réaliser le projet. C'est la première responsabilité du chef de projet d'affecter les rôles aux différents membres de l'équipe. Bien que ceci dépende fortement de la personnalité des individus, on peut donner quelques directives générales à ce niveau, surtout quand la taille de l'équipe ne permet pas d'affecter une personne par rôle.
Le rôle du responsable financier peut être tenu par le chef de projet lui-même: surveillant «d'en haut» la marche générale du développement, il est en bonne position pour cela.
Le gestionnaire de configuration a un rôle très important, et souvent sous-estimé. Ce rôle peut souvent être tenu par le responsable qualité, qui peut également s'occuper de la gestion des composants logiciels. On évitera en revanche autant que possible de faire tenir ce rôle par quelqu'un qui serait également activement impliqué dans le développement: un certain recul par rapport à la conception est en effet nécessaire.
Le rôle du responsable documentation sera également impérativement tenu par quelqu'un qui n'est pas directement un développeur; en particulier, on prendra soin que la documentation utilisateur soit rédigée par quelqu'un qui ne connaît pas directement les modalités techniques de la réalisation. Trop souvent, les contraintes imposées aux utilisateurs résultent des problèmes techniques rencontrés par la réalisation. Le responsable documentation devra en particulier veiller à refuser des solutions «simplificatrices» au niveau de l'implémentation qui apporteraient des complications au niveau de l'utilisation. Ce rôle peut également être tenu par le responsable qualité... sous réserve qu'on ne lui ait pas déjà affecté trop d'autres rôles.
Selon la taille de l'équipe, le rôle du responsable communication est attribué au chef de projet, à sa (son) secrétaire, au responsable qualité...
En tout état de cause, c'est la responsabilité du chef de projet de s'assurer avant tout que chacun des rôles est tenu par quelqu'un de compétent pour ce faire, qu'aucun rôle n'est présenté ni perçu comme moins «noble» qu'un autre, et que chaque responsable a effectivement compris l'étendue de sa mission.
Fonctionnement de l'équipe.
Toute la difficulté du travail en équipe vient de ce qu'il est nécessaire d'établir des règles, des modalités de travail, des procédures à respecter, etc. Or le but final doit être l'efficacité de l'équipe dans son ensemble. Il importe donc de faire en sorte que le mode de travail établi soit une aide pour tous, et non une gêne. La première condition est que chacun des rôles soit rempli par quelqu'un de compétent. Ceci doit être fait de façon positive; en particulier, si l'équipe de développement a été déterminée a priori, cela nécessite d'identifier les compétences de chacun et de les ventiler au mieux sur les différents rôles, sans établir pour cela de hiérarchie de valeurs: tel qui se révèle fort piètre développeur peut faire un excellent gestionnaire de configuration.
De même, les différentes étapes nécessaires à l'acceptation d'un module ne doivent pas être présentées comme des sanctions, mais comme une reconnaissance d'un travail bien fait. Le responsable qualité doit en particulier faire preuve de psychologie, notamment lorsqu'un module ne peut être accepté: il doit fournir son aide au réalisateur en cause, non le pénaliser. En tout état de cause, il faut être sûr que dans l'équipe, tout le monde sait qui est responsable de quoi. L'équipe fonctionnera au mieux si chaque membre perçoit les rôles de chacun des autres membres comme des services complémentaires concourant au succès commun, s'il s'adresse au responsable concerné pour résoudre les problèmes de son ressort (sans chercher à les résoudre lui-même), et si réciproquement il est prêt à aider les autres membres pour les services qui relèvent de ses compétences propres.
=Le choix de la méthode=
Dans la deuxième partie de cet ouvrage, nous avons vu différentes méthodes de développement. Comme il n'existe jamais de remède miracle en informatique (pas plus qu'ailleurs), aucune méthode ne peut, à elle seule, résoudre toutes les classes de problèmes. Lors d'un nouveau projet se pose donc inévitablement le problème du choix de la méthode la plus appropriée. Ce choix est d'autant plus crucial qu'il conditionnera pour une grande part le succès ou l'échec du système. Enfin, choisir la «bonne» méthode n'est pas suffisant: il faut aussi choisir une méthodologie associée et s'assurer qu'elle est acceptée et respectée par ceux qui doivent l'utiliser.
Le choix d'une méthodologie doit donc s'effectuer en deux étapes: déterminer tout d'abord la grande classe de méthode: structurée, entités-relations, orientée objet par composition ou classification; choisir ensuite la méthodologie proprement dite pour mettre en œuvre la méthode. Il existe deux possibilités à ce niveau: soit adopter une méthodologie existante, soit concevoir une méthodologie «maison». Quelle que soit la solution choisie, il faudra veiller à bien définir la méthodologie adoptée et à l'appliquer rigoureusement. Le but étant bien entendu d'éviter la troisième solution: annoncer que l'on suit telle ou telle méthodologie, mais laisser chaque membre de l'équipe l'interpréter ou apporter ses propres «variations» à sa guise (même si c'est pour des motifs -apparemment- tout à fait justifiés); car alors, c'est la porte ouverte aux incohérences et aux difficultés de maintenance.
Critères de choix d'une méthode.
Il existe certes des critères objectifs de choix de méthode; mais là plus qu'ailleurs, d'autres aspects, notamment psychologiques, entrent en ligne de compte: habitudes ou compétences existantes, disponibilité d'outils, formation des personnels... et mode. Nous ne présenterons ici que les critères objectifs; il appartient au chef de projet de les pondérer en fonction des autres facteurs, notamment de la culture d'entreprise. Certaines sociétés par exemple ont un savoir-faire issu directement de l'automatique. Une méthode par machines abstraites, soutenue par une méthodologie telle que Buhr, sera plus adaptée. D'autres ont une longue expérience en composition ou en classification: la tendance naturelle sera de poursuivre dans cette voie, surtout qu'il y a alors plus de chances de pouvoir réutiliser des parties de logiciels ou des composants provenant de développements antérieurs.
Programmation structurée.
Un des principaux avantages des méthodes structurées est qu'il y est plus facile de prévoir les temps d'exécution, puisque l'organisation du logiciel reflète l'ordre d'exécution. On les préférera donc pour des logiciels ayant des fortes contraintes de prédictibilité des temps de réponse. On pourra également être amené à les utiliser lorsque le cahier des charges est purement fonctionnel: il peut alors être plus simple d'implémenter directement celui-ci que de l'adapter à la structure requise par d'autres méthodes. De toute façon, on veillera à ne les utiliser qu'avec des logiciels de taille modeste et pour lesquels la réutilisabilité n'est pas un critère primordial.
Méthode orientée objet.
Les méthodes orientées objet sont les méthodes favorites actuellement de par leur aptitude à maîtriser la complexité et à produire des modules réutilisables. On les préférera sauf raison particulière justifiant une autre méthode.
Reste le problème du critère de décomposition verticale principal: composition ou classification. L'effet de mode conduisant à des «guerres de religion», il est particulièrement délicat de démêler les critères de choix réellement objectifs. Les considérations sur la complexité des dépendances font préférer la composition pour les logiciels de grande taille et à longue durée de vie. La composition se prête également mieux au développement par maquettage progressif que nous avons exposé précédemment, puisque celui-ci est fondé sur la notion de plans d'abstraction. Inversement, si le projet nécessite une écriture rapide et relève du domaine de la recherche, la plus grande dynamicité et la facilité de modification des couches hautes peuvent faire préférer la classification. En fait, il semblerait que les facteurs psychologiques soient prédominants dans ce choix: certaines personnes semblent raisonner spontanément par composition, alors que d'autres adoptent d'instinct une approche par classification. Nous avons pu ainsi assister à de véritables dialogues de sourds entre concepteurs qui se représentaient mentalement leurs objets de façon opposée... Quelle que soit la solution adoptée, il est donc primordial que chacun des membres de l'équipe comprenne l'approche choisie et l'adopte, même si cela ne correspond pas à son mode naturel de pensée.
Entités-relations.
Les méthodes entités-relations tendent à donner la place prépondérante dans la conception aux données, les traitements n'intervenant ensuite que par rapport aux données. De plus, le modèle implicite (mais non obligatoire) est celui des bases de données relationnelles. On utilisera donc plutôt ces méthodes lorsque les données sont prépondérantes, ou lorsqu'une base de données doit constituer le cœur du système informatique.
Ces caractéristiques correspondent souvent aux applications de gestion, mais pas nécessairement. C'est le profil de l'application, et non le fait qu'elle soit classée (ou non) «gestion», qui doit amener à décider d'utiliser ces méthodes.
Choix d'une méthodologie existante.
La solution la plus simple, une fois une méthode choisie, consiste à adopter une méthodologie existante. La méthodologie est publiée, on dispose donc de manuels de référence. On trouvera facilement des stages de formation pour les membres de l'équipe qui ne la connaîtraient pas; enfin, il existe des outils de génie logiciel disponibles commercialement pour la mise en œuvre.
La taille du projet joue un rôle prépondérant dans le choix. D'une façon générale, plus une méthodologie est rigoureuse, détaillée et précise, plus elle est également lourde à mettre en œuvre. La quantité de documentation exigée par HOOD par exemple ne se justifierait pas pour un projet de 10 000 lignes de code (une méthode de Booch «de base» est alors suffisante), alors qu'elle devient indispensable au-delà de 100 000 lignes. Certaines méthodologies sont très (trop?) générales. Par exemple, OMT peut exprimer aussi bien des diagrammes de composition que de classification. Il est parfois souhaitable de fixer des limites à l'utilisation de certaines méthodologies afin de restreindre l'étendue des possibilités, donc de renforcer les contrôles. On préférera, surtout dans de grands projets, des méthodes définissant clairement une dimension verticale, telles que Rose ou HOOD, à d'autres qui tendent à représenter tous les objets du programme «à plat» et offrent moins de possibilités de contrôler la complexité par une découpe en niveaux étanches.
N'oublions pas non plus que si l'on peut choisir une méthodologie au niveau d'un projet, il est souvent nécessaire d'acheter les outils correspondants, et que la direction financière souhaiterait pouvoir les amortir sur plusieurs projets... Il convient donc de pondérer les exigences d'un projet particulier par celles des autres projets typiques de l'entreprise, afin de permettre de réutiliser aussi le matériel... et les conceptions elles-mêmes.
Définition d'une méthodologie d'entreprise.
L'absence d'une méthodologie correspondant bien aux besoins spécifiques d'un projet ou de l'entreprise en général, parfois le prix des outils, peuvent conduire à la décision de définir une méthodologie propre. Bien sûr, cela suppose d'avoir un ou des projets de taille suffisante pour amortir l'effort supplémentaire de définition de la méthode. Dans ce cas, la meilleure solution consiste souvent à repartir d'une «grande» méthodologie bien établie, et à l'adapter localement aux besoins ou contraintes particuliers. Il s'agira donc généralement non de méthodologies complètement nouvelles, mais de «panachages» d'idées empruntées à plusieurs d'entre elles. Nous montrerons dans la cinquième partie comment l'on peut définir une telle méthodologie.
Si le fonds de départ est suffisamment proche d'une méthodologie commerciale, il est parfois possible d'utiliser ou d'adapter des outils existants. Sinon, il faudra développer ses propres outils, ce qui viendra grever lourdement le budget destiné à la définition de la méthodologie. Il existe cependant des «méta-ateliers» de génie logiciel, qui fournissent une base commune permettant à l'utilisateur de créer les outils supports de n'importe quelle méthode.
Réticences et difficultés.
Quelle que soit la méthodologie adoptée, elle doit être comprise et acceptée par ses utilisateurs, sous peine de mauvaise mise en œuvre, avec des résultats inverses de ceux que l'on en attendait. Citons quelques raisons pouvant conduire à un problème d'acceptation:
En conclusion, mieux vaut une méthode moins bonne sur le papier, mais comprise, appliquée et acceptée par l'équipe de développement, qu'une méthode théoriquement parfaite, mais mise en œuvre de façon inappropriée.
Exercices.
=Politiques de projet=
Dans ce chapitre, nous allons aborder les différents éléments qui interviennent dans la phase préliminaire que l'on pourrait baptiser le projet avant le démarrage du projet. Soulignons que dans les projets réels, celle-ci est souvent négligée: les choix correspondants sont alors effectués au hasard, sans réelle étude des différentes possibilités et des compromis nécessaires... avec le risque d'aboutir à des solutions fort éloignées de l'optimum.
Choix du langage de programmation.
Le langage de programmation utilisé dans un projet doit résulter d'un choix délibéré. Même si l'auteur de ces lignes a une préférence particulière, il faut bien reconnaître que de nombreuses contraintes peuvent influencer le choix du langage: temps de développement par rapport à la durée de vie du projet, compatibilité avec l'existant ou héritage provenant de projets plus anciens... L'important étant (encore une fois) d'éviter les choix implicites: ceux qui ne sont motivés par aucune décision rationnelle et dont l'origine ne peut être tracée. Nous discutions ainsi un jour avec un chef de projet dont le développement s'effectuait en C++. Le dialogue s'établit à peu près comme ceci:
Je ne pus obtenir aucune autre justification pour ce choix technologique qui gouvernait tout l'avenir du projet (au demeurant fort important) que le fait que M. Xxx avait estimé que cela ferait bien dans la proposition!
Le langage principal.
En général, il est préférable de n'utiliser qu'un seul langage pour le développement: moins de compétences nécessaires, uniformité du projet, pas besoin d'outils multilangages... Cependant, certaines contraintes particulières peuvent nécessiter d'utiliser plusieurs langages, et nous y reviendrons dans la section suivante. Mais même dans ce cas, l'un d'entre eux doit être choisi comme langage principal, les autres n'ayant qu'un rôle annexe. En effet, si du point de vue technique rien ne s'opposerait au mélange des langages, nous avons vu en première partie que le langage de développement amène avec lui une certaine philosophie, et le mélange des philosophies serait beaucoup plus difficile à gérer que le mélange des langages.
Unique ou principal, un langage doit être choisi, qui imposera sa façon de faire à l'ensemble du projet. Nous avons présenté tout au long de ce livre les avantages d'Ada pour la mise en œuvre des principes du génie logiciel. Voyons donc quelques raisons qui peuvent conduire à ne pas choisir Ada.
Cohabitation de plusieurs langages.
Il existe des cas où il est raisonnable d'utiliser plusieurs langages pour un même développement logiciel. Citons par exemple:
Dans ces cas, on a un langage principal qui fait appel à des services écrits dans d'autres langages. Dans le cas où ce langage principal est Ada, on connaît deux techniques pour assurer la liaison: l'interfaçage direct et les bulles.
L'interfaçage direct consiste à appeler directement des sous-programmes écrits dans les autres langages. On donne une spécification Ada, mais on déclare simplement au moyen d'un pragma Import que les corps correspondants ont été écrits dans l'autre langage. Ceci règle les problèmes de convention d'appel, mais ne garantit pas nécessairement la cohérence des données. Aussi Ada fournit-il le paquetage Interfaces, qui sert de base à des paquetages enfants, un par langage, décrivant en termes Ada les types de données prédéfinis de ces autres langages. La norme a standardisé les paquetages Interfaces.C, Interfaces.FORTRAN et Interfaces.COBOL. Grâce à cela, l'écriture des spécifications Ada correspondant à des bibliothèque
s d'autres langages est grandement facilitée.
Le terme de bulle a été avancé par Pitette [Pit87,Pit88] pour décrire la technique d'interfaçage du système AdLog (CR2A), visant à permettre à des applications Ada d'accéder à de la programmation logique. L'idée de base est que certains problèmes particuliers relevaient de techniques déductives, et que le langage Prolog était alors le plus approprié pour les résoudre. Cependant, un programme réel se limite rarement à ses seules parties déductives: il faut au moins une interface utilisateur. De même, certains systèmes de nature «temps réel» font appel par endroits à des techniques déductives: l'exemple typique étant un système de contrôle aérien utilisant une base de règles pour reconnaître les avions d'après leur signature radar. AdLog permet d'écrire ces algorithmes déductifs en Prolog, et un précompilateur associé les traduit en structures de données Ada. Celles-ci sont alors exécutées par un interpréteur Prolog, lui-même écrit en Ada. Ainsi, un programme physiquement «tout Ada» peut par moments faire appel à des résolutions isolées, les «bulles», écrites en Prolog (et mises au point par un interpréteur Prolog classiques).
D'autres interfaçages utilisent des techniques voisines sans employer la même dénomination, notamment SAME [Iso94]. L'idée de base est qu'une application de gestion comporte des parties purement procédurales couplées à des requêtes à des bases de données. SAME définit un langage, SAMEdL (SAME Definition Language), dont la syntaxe est proche d'Ada, mais la sémantique dérivée de SQL. Ce langage est facilement accessible à des spécialistes des bases de données chargés de réaliser la partie «requêtes». Un outil génère automatiquement l'interface vers le système de base de données et produit des paquetages dont les spécifications constituent des vues «Ada» des requêtes. La partie impérative peut alors être réalisée en Ada par des concepteurs ne connaissant pas nécessairement les subtilités des bases de données.
Organisation des bibliothèques de programme.
La bibliothèque de programme joue un rôle central pour le développement en Ada. Son organisation est de la responsabilité du gestionnaire de configuration. Il devra étudier les différentes possibilités offertes par le compilateur et décider d'une structure adaptée au projet. Par exemple, si le compilateur dispose à la fois des notions de liens et de sous-bibliothèque, on peut décider d'avoir une bibliothèque principale pour les développements spécifiques du projet, et une autre (qui sera liée à la première) pour les composants logiciels stables et immuables. De plus, la bibliothèque principale contiendra les modules officiellement acceptés (après contrôle par le responsable qualité), alors que chaque développeur bénéficiera d'une sous-bibliothèque pour ses développements propres. Ainsi, chacun peut bénéficier des modules «officiels» de ses voisins et travailler sur ses propres modules en développement sans perturber les autres membres.
Bien entendu, ceci n'est qu'un exemple, et d'autres organisations sont possibles selon les besoins du projet et les possibilités du compilateur; en particulier, le mécanisme des liens, des familles et/ou des sous-bibliothèques peut être mis à profit pour gérer les versions successives du logiciel, les différentes options de configuration (ou les différentes cibles)... Certains environnements de développement offrent des possibilités extrêmement évoluées en la matière. Il n'existe qu'un seul point absolu à retenir: l'utilisation des possibilités doit être décidée, dès le début du projet, et appliquée de façon uniforme par tous les membres de l'équipe. Bien entendu, comme tout choix fondamental, ces décisions d'organisation doivent être justifiées et référencées dans le document conservant les raisons des choix de conception.
Style et restrictions d'usage.
Le style d'un projet se doit d'être uniforme: cela facilite la maintenance en permettant à n'importe qui d'intervenir sur n'importe quelle partie d'un projet sans risquer d'être dépaysé. De nombreuses études ont été publiées sur le sujet; en ce qui concerne Ada, la meilleure base de départ est l'«Ada Quality and Style Guide» [Spc94], édité aux États-Unis par le "Software Productivity Consortium" et mis dans le domaine public, ce qui permet de l'utiliser sans problème. Il donne de nombreux conseils et présente les solutions les plus fréquemment retenues. Il convient cependant de l'«instancier», c'est-à-dire de choisir les modalités pratiques à utiliser en fonction des grandes lignes qu'il présente. Quelques points qu'il importe de fixer sont:
Suivant le domaine du projet, il peut également être nécessaire d'interdire l'utilisation de telle ou telle possibilité du langage. Par exemple, un logiciel devant fonctionner 24 heures sur 24 interdira l'usage de toute allocation dynamique, car même si l'on peut assurer que toute variable allouée est rendue au bout d'un temps fini, cela ne garantit pas que la mémoire ne s'épuisera pas, à cause des effets de fragmentation mémoire. Une telle restriction ne s'applique parfois pas aux phases de démarrage de l'application, où l'on peut créer des variables de façon dynamique, mais ces variables dureront de façon permanente. On rencontre également des restrictions sur l'emploi du parallélisme, totales (pas de tasking) ou partielles (pas de rendez-vous, ou au contraire pas de types protégés). Certaines contraintes de validabilité peuvent exiger également l'interdiction de fonctionnalités de haut niveau. Depuis Ada 95, il existe un pragma Restriction qui permet de faire vérifier par le compilateur la bonne application de ces restrictions d'usage. On peut ainsi écrire:
pragma RESTRICTION (No_Tasking);
 -- Pas de parallélisme
pragma RESTRICTION (No_Dynamic_Allocation);
 -- Pas de pointeurs
-- etc.
Il existe également des outils permettant de vérifier la bonne application des règles de style, ou de remettre un code «aux normes». La forme la plus courante de ce genre d'outil est le «reformateur» (ou «pretty printer»), fourni avec la plupart des compilateurs Ada, qui redonne une présentation correcte à un programme. Comme en matière de style l'uniformité est plus importante que les détails du style adopté, il est parfaitement raisonnable d'adopter la politique des outils dont on dispose. Le compilateur GNAT possède par exemple une option de compilation qui refuse toute unité dont la présentation ne correspond pas au style... adopté par les auteurs du compilateur. Bien qu'initialement destinée à l'écriture du compilateur lui-même, on peut parfaitement décider d'adopter cette présentation, d'ailleurs fort acceptable, pour la seule raison que l'on dispose du moyen de la faire vérifier automatiquement.
Politiques de gestion des erreurs.
Principes généraux.
Lorsque l'on parle de gestion des erreurs, l'on pense immédiatement «exceptions». C'est bien normal, puisqu'Ada a popularisé cette puissante fonctionnalité qui fait défaut à beaucoup de langages, même si le concept figurait déjà dans des langages antérieurs. Notons au passage que la plupart des langages ultérieurs (notamment C++ et Eiffel) ont également adopté des fonctionnalités de déroutement sur événements exceptionnels. On trouvera dans [Goo88] un résumé des principales contraintes liées à l'utilisation des exceptions. Il ne faudrait cependant pas croire que les exceptions soient suffisantes pour gérer les erreurs d'exécution: elles constituent seulement un mécanisme linguistique indispensable pour implémenter des politiques de gestion d'erreurs, qui doivent être définies en fonction du contexte du projet. 
Pour bien comprendre le problème, revenons sur ce qu'est une situation exceptionnelle. Un sous-programme est chargé de rendre un certain service, pour un client (le sous-programme qui l'appelle). Or il est parfois impossible de rendre le service demandé: lecture s'il n'y a plus de données, inversion de matrice singulière, panne de périphérique... Ce sont ces cas que nous appelons des cas exceptionnels. Noter que ce ne sont pas nécessairement des erreurs: le fait d'atteindre une fin de fichier, par exemple, est une condition exceptionnelle (la boucle normale de traitement ne peut se poursuivre) sans être forcément une erreur (le cas est prévu dans le logiciel et constitue une situation normale). On peut dire qu'une condition anormale cesse d'être une erreur pour n'être plus qu'une exception lorsque la condition est propagée à un niveau qui l'a prévue et qui sait comment réagir pour la corriger ou la faire disparaître. La gestion des cas exceptionnels comporte trois aspects distincts: le diagnostic, le signalement et le traitement.
Politique de correction locale.
Cette stratégie consiste à essayer de remédier au problème à la source: le service qui diagnostique un problème tente d'y remédier tout seul en interne: il n'y a donc pas signalement. Cette stratégie est fréquemment employée dans les langages sans exceptions: une fonction recherchant une sous-chaîne dans une chaîne renverra la valeur 0 si la sous-chaîne n'est pas trouvée, une fonction mathématique imprimera un message d'erreur au terminal en cas d'argument incorrect, etc. Comme il n'y a pas signalement, il ne peut y avoir traitement: le client n'a aucun moyen d'influer sur la réponse à un incident, ni même de savoir qu'un incident s'est produit. Diagnostic, signalement et traitement sont donc totalement figés à l'intérieur de la procédure appelée.
Politique du code de retour.
La première technique est celle du code de retour: on renvoie une valeur particulière qui indique si le traitement a pu être effectué. Le diagnostic est donc interne. Plusieurs formes sont possibles: utilisation d'un paramètre out:
procedure Traitement (Résultat : out Status);
valeur renvoyée par une fonction:
function Traitement return Status;
ou utilisation d'une variable globale:
Résultat : Status;
procedure Traitement;
La première forme, qui paraît plus logique, est peu utilisée en pratique, car elle oblige l'utilisateur à déclarer une variable supplémentaire, et ne peut être utilisée (en Ada) avec des fonctions.
La deuxième forme est utilisée quasiment systématiquement en C, où l'absence de typage rend cette forme parfois commode. Ainsi, une fonction peut renvoyer un pointeur, ou la valeur 0 (qui est également la valeur logique fausse!) si l'élément n'a pas été trouvé. L'inconvénient de cette façon de faire est qu'elle impose l'utilisation d'une fonction (par opposition à une procédure).
La troisième technique permet de garder les fonctionnalités sous forme de procédures ou de fonctions au choix de l'utilisateur, en n'imposant pas de déclaration de variable supplémentaire; en revanche elle est difficilement utilisable en présence de parallélisme, car entre le moment où une tâche appelle la procédure Traitement et le moment où elle va lire la variable Résultat, une autre tâche a pu appeler la procédure, et rien ne garantit que le résultat lu corresponde à la bonne exécution! Et pourtant, cette technique est largement utilisée pour les interfaces, en particulier avec POSIX qui renvoie toujours les résultats dans la variable ERRNO.
L'avantage du signalement par code de retour est qu'il est synchrone: l'utilisateur est maître de l'endroit où il effectue le test de bon achèvement du composant utilisé, et ce test apparaît explicitement dans le texte du programme. Le problème est que rien n'oblige l'utilisateur à effectuer le test, qu'il est très facile de l'oublier, et qu'alors le programme continuera en croyant qu'une certaine action a été effectuée, alors qu'il n'en est rien. Bien sûr, la forme «fonction» oblige l'utilisateur à effectuer le test, mais conduit souvent à une imbrication de if très lourde et pénible, non seulement à écrire, mais aussi à maintenir. Remarquer qu'en C, il est possible d'appeler une fonction comme si c'était une procédure, et que le résultat est alors perdu: l'utilisateur aura tendance à le faire pour des appels dont il est sûr qu'ils ne peuvent pas échouer... ce qui conduit à des catastrophes si le service échoue quand même pour des circonstances qui avaient échappé au programmeur.
Politique du déroutement.
La politique du déroutement consiste à appeler une procédure, fournie par l'utilisateur, lors de la survenue d'événements exceptionnels. On peut la voir comme une politique de traitement local, mais où le traitement est fourni par l'utilisateur au lieu d'être imposé d'office. Certains langages offrent cette politique en standard: en PL/I par exemple, l'utilisateur peut annoncer son intention de traiter les cas de division par zéro en déclarant:
codice_1
En cas d'anomalie, le compilateur appelle la procédure déclarée, qui est censée remédier au problème, à la suite de quoi on reprend le traitement normal. Il est facile d'implémenter cette politique en Ada. Il suffit de déclarer:
package Gestion_Erreur is
 type Traitement_Erreur is access procedure;
 procedure Activer (Traitement : Traitement_Erreur);
 procedure Désactiver;
 function Traitement_Courant return Traitement_Erreur;
 procedure Signaler;
end Gestion_Erreur;
On active le traitement en l'enregistrant par la procédure Activer (équivalent de la clause ON de PL/I) et on le désactive par Désactiver; la fonction Traitement_Courant renvoie un pointeur sur la procédure de traitement courant, ce qui permet de la conserver et de la restaurer plus tard. En cas de condition anormale, on appelle la procédure Signaler, qui appellera la procédure de traitement active. On peut prévoir une procédure par défaut (ou lever Program_Error) si aucune procédure de traitement n'a été prévue. On peut perfectionner ce paquetage en fournissant un paramètre à Signaler et aux procédures de traitement pour indiquer la cause de l'erreur: ce peut être une chaîne de caractères, un identificateur d'exception (Exception_ID, déclaré dans le paquetage Ada.Exceptions), etc.
Avec cette politique, le diagnostic est interne, le signalement est asynchrone: celui qui fournit la procédure ne sait pas a priori quand celle-ci sera appelée, et le traitement est interne: il s'effectue depuis le sous-programme appelé. Noter que la procédure de traitement peut décider de lever une exception, et que le composant doit y être préparé (et la laisser filer). Une difficulté est qu'il faut impérativement prévoir de «débrancher» la procédure dès que l'on sort de la zone où celle-ci s'applique, sous peine de voir un traitement prévu pour travailler dans un certain contexte appelé dans un contexte totalement différent. Enfin, l'activation étant exécutable, il n'est pas possible en lisant un morceau de programme de savoir quel est le traitement actif: il faut reconstituer tout l'historique de l'exécution du code.
Une variante de cette politique a été proposée par Kruchten [Kru89, Kru90], sous le nom de politique sans exception. Elle consiste à transformer les composants en génériques auxquels on passe une procédure utilisateur, appelée par le composant en cas de problème:
generic
 with procedure Traiter_Erreur;
package Le_Composant is...
Le signalement s'effectue comme dans le cas précédent par appel de la procédure fournie. Cette variante présente l'avantage que l'attachement d'un traitement est spécifique du composant, statique, et permanent dans la portée de l'instanciation. Il n'y a donc plus le problème de l'oubli du «débranchement» du traitement. Une solution intermédiaire consiste à fournir à la procédure appelée un pointeur sur une procédure à appeler en cas de problème:
 type Traitement_Erreur is access procedure;
 procedure Service (Autres paramètres...;
 Erreur : Traitement_Erreur);
Politique du contrat.
Cette technique, qui a été développée dans [Mey90], vise à empêcher les erreurs de se produire, en définissant un contrat entre l'appelé et l'appelant permettant de garantir la bonne fin du service rendu. L'idée de base est que le diagnostic doit toujours être externe. Chaque module déclare les préconditions qui doivent être respectées pour garantir son bon fonctionnement; moyennant le respect des préconditions, celui qui fait le composant garantit la vérification de la post-assertion. Pour une pile par exemple, la précondition de l'opération Dépiler est que la pile est non vide. L'opération Dépiler peut garantir la fourniture de la valeur du sommet de pile (post-assertion) à la condition que la précondition soit vérifiée. La vérification du bon respect des préconditions est à la charge de l'appelant, en conséquence de quoi aucune erreur ne peut se produire. On doit donc 
écrire par exemple:
if not Pile_Vide then
 Dépiler (Valeur);
else
 Signaler_erreur;
end if;
Avec cette politique, le diagnostic est synchrone, puisque programmé par l'appelant, et confondu avec le signalement ; diagnostic, signalement et traitement sont entièrement à la charge de l'appelant. Ce que ne précise pas le modèle du contrat, c'est le comportement de la procédure Dépiler si on l'appelle malgré tout avec une pile vide: le résultat peut alors « légalement » être n'importe quoi : si l'appelant ne respecte pas sa part du contrat, alors l'appelé n'est plus tenu à rien. Cette méthode est donc basée sur un principe de confiance mutuelle : à l'intérieur du composant, on suppose que les pré-assertions tiennent, et on ne se préoccupe pas de ce qui pourrait se passer dans le cas contraire. C'est à l'appelant de vérifier avant l'appel le bon respect des préconditions, et s'il ne respecte pas son contrat, tant pis pour lui... 
Hélas, ce système est totalement opposé au principe de programmation fiable. Si suite à une erreur dans un paramètre d'appel, votre composant a pour effet de «planter» totalement le programme, avec pour conséquence que l'avion dont c'était le système de contrôle va s'écraser avec ses 300 passagers sur les Champs-Élysées un jour de 14 juillet, votre responsabilité est dégagée : il ne fallait pas vous appeler de cette façon. Oui, certes, mais n'aurait-on pas pu spécifier aussi le comportement en cas d'appel incorrect, de façon à permettre de signaler une erreur, lancer une procédure de secours pour passer le système en mode dégradé, et éviter une catastrophe ?
Aussi cette politique ne dispense-t-elle pas d'une programmation défensive: les seules garanties que l'on a lors de l'appel d'un composant sont celles fournies par le langage. Ceci conduit généralement à une redondance de tests, puisque le composant vérifie que l'appelant a respecté sa part de contrat. En revanche, les signalements sont simplifiés, puisque tout diagnostic correspond à une faute de programmation de l'appelant: on se contentera en général de lever l'exception Program_Error. Cette exception ne sera en principe pas traitée par l'appelant, sauf pour sortir correctement du programme, en fermant tous les fichiers par exemple... et en demandant à l'utilisateur d'envoyer un rapport d'anomalie !
Les assertions jouent un rôle important dans cette politique, ce qui a amené Eiffel a introduire cette notion dans le langage lui-même. Cela n'a pas été jugé nécessaire en Ada, car il est extrêmement aisé d'écrire un paquetage fournissant cette fonctionnalité, au moins dans les cas simples. Les cas plus sophistiqués sont plutôt du niveau d'outils d'environnement, comme Anna [Luc90], qui permettent de rajouter des contrôles d'assertions à l'aide de commentaires spéciaux traités par un préprocesseur.
Politique par exceptions simples.
Cette politique, entièrement fondée sur le système d'exceptions du langage, consiste à signaler l'erreur par levée d'exception. L'utilisateur traite les erreurs dans des traite-exceptions. Le diagnostic est interne, le signalement est asynchrone, et le traitement est externe à la procédure qui effectue le diagnostic. Elle possède de nombreux avantages: sécurité, impossibilité d'ignorer la présence de l'anomalie, possibilité de sortir de nombreux niveaux imbriqués directement... C'est la seule possible pour des composants logiciels de base.
Elle présente cependant certains inconvénients lorsqu'elle est utilisée systématiquement à travers de nombreuses couches [Kru89, Kru90]. Le principe d'encapsulation veut que l'implémentation d'une fonctionnalité soit invisible à l'appelant ; or si celle-ci appelle des couches de plus bas niveau susceptibles de lever des exceptions, il faut empêcher la propagation de celles-ci, puisqu'elles proviennent d'un niveau invisible à l'utilisateur. Ceci conduit à une multiplication de traite-exceptions, en particulier dans les paquetages «relais» qui n'ont d'autre fonction que d'encapsuler, suivant le schéma ci-dessous :
package Haut_Niveau is
 procedure Service;
 Erreur : exception;
end Haut_Niveau;
with Bas_Niveau;
package body Haut_Niveau is
 procedure Service is
 begin
 Bas_Niveau.Service;
 exception
 when Bas_Niveau.Erreur => raise Haut_Niveau.Erreur;
 end Service;
end Haut_Niveau;
S'il y a consignation des erreurs, le problème est pire: en effet dans ce cas on exige que tout traite-exception consigne l'anomalie. Lorsqu'une exception traverse ainsi de nombreuses couches, on se trouve envahi par une avalanche de consignations qui ne signalent en fait que le passage de l'exception.
Politique Oméga.
Cette politique a été également proposée par Kruchten [Kru89, Kru90], et s'applique principalement aux types de données abstraits. Elle consiste à introduire un état supplémentaire décrivant si la donnée est valide ou invalide. L'état invalide est appelé «Oméga», par référence à la valeur OM du langage SETL [Sch86]. Toute opération portant sur des données «Oméga» fournit un résultat «Oméga». Une fonction permet de savoir si une valeur est dans l'état «Oméga»; une variante non exclusive consiste à lever une exception dans ce cas. Un type entier ainsi protégé s'exprime comme:
package Entier_Protégé is
 type Entier is private;
 function Vers_Entier (X : Integer) return Entier;
 function Vers_Integer (X : Entier) return Integer;
 function "+" (Gauche, Droite : Entier) return Entier;
 function "-" (Gauche, Droite : Entier) return Entier;
 -- etc..
 function Est_Oméga (Valeur : Entier) return Boolean;
 Erreur_Oméga : exception;
 procedure Erreur_si_Oméga (Valeur : Entier);
private
 type Entier is 
 record
 Est_Oméga : Boolean := False;
 Valeur : Integer; -- par exemple
 end record;
end Entier_Protégé;
L'intérêt de cette méthode est qu'il n'est plus nécessaire de prévoir des traite-exceptions sur toutes les phases de calcul intermédiaires; le signalement est synchrone (par appel de la procédure Erreur_si_Oméga, ou de la fonction Est_Oméga si on ne veut pas d'exception), mais le traitement est externe. La fonction Vers_Integer lève l'exception Erreur_Oméga si on l'appelle sur la valeur «Oméga», ce qui garantit qu'il est impossible de faire quoi que ce soit d'une valeur incorrecte; on évite donc ainsi l'insécurité généralement liée au signalement synchrone (si on oublie d'effectuer le test).
Politique de gestion centralisée.
Une autre possibilité pour limiter les phénomènes de cascade d'erreurs est la gestion centralisée des erreurs: lors de toute anomalie, au lieu de lever une exception, on appelle une procédure chargée de signaler l'erreur. Ceci peut s'exprimer sous la forme:
package Gestion_Erreurs is
 procedure Erreur (Message : String := "");
 function Message_Erreur return String;
 Erreur_Traitée: exception;
end Gestion_Erreurs;
La procédure Erreur stocke le message, consigne l'anomalie si c'est demandé par le projet, puis lève l'exception Erreur_Traitée. Les traite-exceptions de la chaîne d'appel traitent les autres exceptions par appel de la procédure Erreur, mais laissent filer l'exception Erreur_Traitée, ou interrogent la fonction Message_Erreur pour connaître la cause de l'erreur d'origine. L'erreur n'est consignée qu'une fois, au moment de l'appel, et il n'est pas nécessaire de transformer une erreur provenant de couches profondes, car tous les modules ont la visibilité du paquetage Gestion_Erreurs.
Cette politique a les mêmes caractéristiques que celle par exceptions simples: diagnostic interne et signalement asynchrone. Elle évite cependant les effets de cascade en divisant le problème en deux: le traitement systématique est interne et préalable au signalement, ce qui laisse la possibilité d'un traitement complémentaire externe, dépendant du contexte de l'appelant.
Récapitulatif des différentes politiques.
Le tableau de la figure 35 récapitule et compare les principales caractéristiques des politiques que nous avons mentionnées. Il rappelle la répartition des responsabilités entre le client (l'appelant) et le fournisseur de service (l'appelé). Toutes les combinaisons ne sont bien entendu pas possibles: par exemple, un traitement interne implique nécessairement un diagnostic interne.
Figure 35: Les différentes politiques d'erreurs
Exercices.
=Les choix de la phase de réalisation=
Les choix que nous avons vus jusqu'à présent sont typiquement effectués au démarrage du projet. Mais tout au long de la conception et du codage, on est encore amené à prendre des décisions; là encore, il importe que celles-ci résultent d'une analyse délibérée et non de ce qui passait par la tête du programmeur à ce moment.
Dans le chapitre précédent, nous avions situé les problèmes généraux et montré comment Ada permettait d'y répondre; en revanche, nombre de choix exposés dans ce chapitre ne s'offrent qu'avec Ada. Est-ce qu'Ada pose plus de problèmes? Non, c'est seulement qu'Ada est un langage plus riche, et que le programmeur dispose de nombreuses possibilités là où d'autres langages lui imposent une seule façon de faire. La «simplicité» dont se vantent certains langages n'est en fait qu'un moyen de cacher la pauvreté des moyens offerts.
Identificateurs et règles de nommage.
Choisir des bons identificateurs est un point important qui est loin d'être évident. Ada offre de nombreuses possibilités, encore faut-il les utiliser à bon escient. Remarquons que tous les caractères d'un identificateur Ada sont significatifs et qu'il n'existe pas de limitation à leur longueur.
Nous allons commencer par un exemple et, exceptionnellement, nous utiliserons des identificateurs anglais. Nous verrons plus loin que l'utilisation d'identificateurs en français complique encore le problème!
Supposons que nous ayons quelque chose qui s'appelle un «contexte» (context), que nous voulions empiler (push) à l'entrée d'une procédure, pour pouvoir le restaurer à la sortie. Une expression informelle du problème serait:
Si nous voulons que l'expression Ada ressemble à cette phrase, nous pouvons le faire de différentes façons:
Context.Push (Old); -- (1)
Push (Old_Context); -- (2)
Push_Context (Old); -- (3)
Push (Context => Old); -- (4)
Push (Context'(Old)); -- (5)
Push_Old_Context (X); -- (6)
Ainsi que les combinaisons des précédents :
Context.Push (Old_Context); -- (7)
Context.Push_Context(Context => Old_Context); -- (8)
-- etc.
En (1), nous avons un paquetage appelé Context contenant une procédure Push, et une variable Old. Dans les autres exemples, nous ne nommons pas le paquetage; nous supposons que la clause use a été utilisée. En (2), la procédure s'appelle Push et la variable Old_Context, alors qu'en (3) la procédure s'appelle Push_Context et la variable Old. En (4), la procédure s'appelle également Push, mais la variable s'appelle Old et nous profitons d'une association nommée pour exprimer que nous empilons un contexte. En (5), la procédure s'appelle Push et la variable Old, mais nous utilisons la qualification par Context, dont nous supposons que c'est le type de la variable Old. En (6), la procédure s'appelle Push_Old_Context et ce nom porte toute l'information ; le nom de la variable (X) est sans importance.
Quel est le meilleur choix? Si vous n'êtes pas convaincu que ce n'est pas évident, faites un sondage parmi vos collègues ; il est vraisemblable que chaque solution aura la préférence de quelqu'un...
Considérations générales.
Une règle fréquente est que le nom d'une entité doit être «parlant» et «suffisamment long». Un indice clair de nom mal choisi est quand le programmeur ressent le besoin de mettre un commentaire avec la déclaration d'une entité pour décrire ce à quoi elle sert. Les noms doivent se comprendre d'eux-mêmes! Bien qu'il y ait une forte part de subjectivité dans l'appréciation de la lisibilité d'un nom, rappelons quelques règles générales:
Il existe même des outils vérifiant que tous les noms d'un programme ont au moins cinq lettres et appartiennent à un dictionnaire. Ceci fonctionne correctement en général, mais n'est pas une règle suffisante. Une formulation plus subtile serait:
En rajouter plus qu'il n'est nécessaire a un effet néfaste sur la lisibilité. Par exemple, Line_Length est un identificateur excellent, mais The_Length_Of_The_Line_That_I_Am_Considering est absolument désastreux. Une autre règle «évidente» est:
Ceci est moins simple qu'il n'y paraît. Par exemple, [Boo84] utilise un paquetage Complex qui déclare le type Number ; ceci fait de Complex.Number un nom très parlant. Mais le paquetage lui-même n'est pas un complexe; le vrai complexe, c'est le type Number, ce qui est totalement trompeur. L'erreur provient ici de ce que le choix suppose a priori une forme d'utilisation du nom. Dans un composant logiciel, le concepteur ignore comment le nom sera utilisé. Ce qui nous amène à la règle suivante :
Avec l'exemple précédent, nous pouvons appeler le paquetage Complex_Handler et le type Complex_Number. On aurait alors les déclarations suivantes :
 X : Complex_Number; -- préférentiel
 Y : Complex_Handler.Complex_Number; -- acceptable
Noter que nous favorisons ici l'utilisateur de la clause use.
Utilisation de la surcharge.
Doit-on choisir un nom spécifique pour chaque opération, ou doit-on essayer de réutiliser des noms existants en profitant du mécanisme de surcharge? Dans notre exemple initial, est-il préférable de nommer la procédure Push (qui pourrait être surchargée pour des piles d'autres choses que des contextes), ou Push_Context, qui serait unique dans le système? Le but de la surcharge est l'abstraction: se concentrer sur les similitudes en ignorant pour un temps les différences. La notion d'empilement est la même, qu'il s'agisse de contextes ou d'entiers; le nom Push est donc préférable.
Prenons un autre exemple. Supposons que nous ayons une procédure qui efface (clear) l'écran et une autre qui réinitialise (clear également) une variable. Les spécifications suivantes sont autorisées par le langage:
procedure Clear; -- L'écran
procedure Clear (V : out Some_type);-- La variable
Ici, les deux Clear ne désignent pas la même notion. Dans ce cas, nous recommandons d'appeler la première Clear_Screen. On pourrait garder le nom Clear pour la seconde, car l'argument obligatoire montrerait clairement ce que la procédure réinitialise. Nous pouvons en déduire la règle:
La question de la langue.
Faut-il donner aux entités un nom anglais ou utiliser le français (ou l'allemand, ou ...)? La réponse dépend du contexte général du projet. En faveur du français, on trouvera essentiellement le fait qu'il est plus compréhensible... pour les Français, qui parlent souvent assez mal les langues étrangères. Bien que ce soit l'inclinaison naturelle de beaucoup de programmeurs, cela a cependant un certain nombre d'inconvénients. Lorsque l'on développe un composant logiciel réutilisable, l'anglais est quasiment obligatoire: sinon, le composant serait inexportable en dehors du marché français, sauf à maintenir deux versions de chaque composant. La maintenance peut être simplifiée avec un outil qui traduit automatiquement les identificateurs au moyen d'un dictionnaire, mais l'effort reste important. Une solution plus pratique est de ne dédoubler que la spécification (la seule qui importe à l'utilisateur). Le corps n'utilise que les noms français, avec quelques renames pour assurer la compatibilité avec la spécification. Les seules modifications au corps en cas de changement de langue de la spécification seraient dues à la concordance des corps de sous-programmes avec leurs spécifications.
Cette question est moins cruciale pour les développements spécifiques, puisque le problème de l'exportation des sources ne se pose pas. Mais il existe d'autres raisons; l'utilisation du français diminue la lisibilité, tout au moins pour les personnes maîtrisant suffisamment l'anglais, du fait qu'Ada utilise l'anglais pour les mots réservés. L'instruction suivante est presque du langage naturel en anglais :
 if Device_is_not_ready then Call_operator ...
alors que sa traduction rappelle la nature « informatique » de la formulation :
 if Periph_non_prêt then Appeler_opérateur ...
Enfin, il faut bien reconnaître que la structure de l'anglais se prête mieux aux formulations concises et que le fait de mettre les adjectifs avant les noms correspond mieux à la notion de qualification. Si Complex.Number « sonne » bien en anglais, on ne saurait en dire autant de Complexe.Nombre... Tandis que codice_2 sonne correct en français, respectant l'ordre des mots de la langue, et ayant l'avantage que Nombre soit en premier pour regrouper tous les types de nombres.
Discussion de l'exemple.
Après avoir vu ces différentes règles, critiquons les différentes possibilités de notre premier exemple.
Nous ne discuterons pas de toute la combinatoire des solutions précédentes, mais notons tout de même que (8) ne saurait être qualifié de lisible!
Démarche.
Compte tenu des remarques précédentes, on voit que le choix d'un nom, surtout pour les entités exportées, ne doit pas être fait à la légère. Nous suggérons que le concepteur considère les points suivants pour faire son choix:
Utilisation de la clause «use».
L'utilisation ou l'interdiction de la clause use est un vaste sujet de débat dans la communauté Ada [Bry87] [Boo86] [Ros87]. Nous allons tenter de le présenter succinctement et d'en tirer quelques conclusions.
Notons tout d'abord que le débat n'est pas tant celui de la clause use que celui de savoir si l'on doit utiliser des noms complets (préfixés par le nom du paquetage) ou des noms simples. En effet, rien n'empêche d'utiliser les noms complets même en présence d'une clause use, tout comme il est possible (par surnommage) d'utiliser des noms simples sans clause use. Disons simplement que si un projet choisit d'imposer des noms complets partout, alors il doit interdire les clauses use car cela permet de faire vérifier par le compilateur la bonne application de la règle.
Choix des types.
La définition des types est une des étapes les plus importantes de l'écriture d'une application en Ada. C'est la rigueur du typage qui permet d'assurer le maximum de contrôle par le compilateur; mais inversement, le programmeur (nouvellement) converti à Ada tend à définir trop de types, provoquant ainsi une multiplication des conversions nuisant en définitive à la lisibilité.
Prenons un exemple typique: soit une procédure Aller_en, destinée à positionner le curseur en un point de l'écran. Cette procédure admet à l'évidence deux paramètres, correspondant aux coordonnées. Une première solution consiste à définir des types différents pour les abscisses et les ordonnées:
type Abscisse is range 0..799;
type Ordonnée is range 0..599;
procedure Aller_en (X : Abscisse; Y : Ordonnée);
Cette solution présente l'avantage de faire vérifier par le compilateur que l'on ne mélange pas les coordonnées. Ainsi si l'on écrit:
 X_courant : Abscisse := 1;
 Y_courant : Ordonnée := 1;
begin
 Aller_en (Y_courant, X_courant);
le compilateur diagnostiquera que les paramètres ont été inversés dès la première compilation.
L'inconvénient de cette solution est que l'on a souvent à effectuer des calculs mixtes portant à la fois sur des lignes et des colonnes. Par exemple, pour calculer le périmètre d'un rectangle, nous devons évaluer l'expression 2*((Y2-Y1)+(X2-X1)). Quel est le type de ce calcul? Certainement ni une abscisse, ni une ordonnée. Ce ne serait pas homogène du point de vue physique et de plus le résultat peut prendre des valeurs en dehors de l'intervalle défini pour nos coordonnées. Le programmeur se rabat souvent dans de tels cas sur un type prédéfini, comme Integer et la formule devient 2*(Integer(Y2-Y1)+Integer(X2-X1)). Mais rien n'assure que le type Integer convienne et en particulier qu'il comporte suffisamment de valeurs.
La seconde solution consiste à définir un seul type «coordonnées», d'intervalle suffisamment grand pour tenir non seulement les valeurs d'abscisse et d'ordonnée, mais encore les résultats des calculs intermédiaires entre abscisses et ordonnées. Pour améliorer les vérifications, nous définirons Abscisse et Ordonnée comme des sous-types de Coordonnées:
X_max : constant := 799;
Y_max : constant := 599;
type Coordonnées is
 range -(X_max+Y_max) .. (X_max+Y_max);
subtype Abscisse is Coordonnées range 0 .. X_max;
subtype Ordonnée is Coordonnées range 0 .. Y_max;
procedure Aller_en (X : Abscisse; Y : Ordonnée);
Ici, nous perdons toute vérification à la compilation d'éventuelles inversions entre abscisses et ordonnées, puisque précisément cette solution présente l'avantage de permettre de les mélanger dans les calculs. Il reste cependant certains garde-fous. D'une part, l'utilisation des sous-types nous garantit que l'exception Constraint_Error sera levée si jamais une valeur trop grande (ou négative) était passée à l'une des coordonnées. D'autre part, l'utilisation de la notation nommée rend beaucoup plus improbable un mélange de coordonnées. On voit mal un programmeur écrire (à moins de le faire vraiment exprès):
Aller_en (X => Y_milieu, Y => X_milieu);
Notons une fois de plus l'importance du choix de bons identificateurs! Cette sécurité disparaîtrait si nous appelions nos paramètres formels A et B au lieu de X et Y.
La règle la plus simple à appliquer est qu'il faut utiliser des types différents pour représenter des entités réellement différentes. Une prolifération de conversions doit amener à se demander s'il ne serait pas judicieux de fusionner certains types.
Types discrets.
Le terme «type discret» recouvre les types énumératifs et les types entiers, c'est-à-dire les types que l'on utilise chaque fois que l'on souhaite représenter un élément du monde réel caractérisé par un nombre fini de valeurs distinctes.
Si l'on souhaite simplement représenter différentes valeurs, les types énumératifs s'imposent: ils sont plus lisibles (leur nom indique à quoi ils correspondent) et plus sûrs, notamment grâce au fait que les instructions case vérifient toujours que toutes les valeurs possibles ont été mentionnées: en cas de rajout d'une valeur au type suite à une modification du logiciel, le compilateur vous préviendra gentiment de tous les endroits où vous avez oublié d'ajuster les case correspondants. Enfin, le fait de devoir énumérer toutes les valeurs oblige à réfléchir dès le départ à l'ensemble des valeurs possibles.
On réservera les types entiers au cas où l'on souhaite vraiment une sémantique de nombre, c'est-à-dire lorsque la variable est utilisée pour compter ou lorsque l'on a besoin d'effectuer des opérations arithmétiques. Et même alors, on évitera ABSOLUMENT l'utilisation du type Integer, sauf dans quelques cas que nous mentionnerons par la suite. Pourquoi l'avoir écrit si gros? Parce qu'à l'usage, il s'avère que c'est une des habitudes les plus difficiles à faire perdre aux nouveaux programmeurs Ada.
Le choix d'un type de données s'effectue en étudiant le domaine de problème; c'est vrai des types entiers comme des autres. En particulier, on doit toujours penser qu'un type entier est limité par la machine et que l'utilisation d'un type prédéfini ne dispense pas d'étudier le problème des bornes ; ceci s'applique à tous les langages, mais seul Ada fournit le moyen de choisir les types de façon indépendante de la machine. On distingue plusieurs formes caractéristiques de déclarations de types en fonction des contraintes:
type Longueur_minimale is range 0..200;
subtype Longueur_Utile is Longueur_minimale'Base;
type INT_16 is range -2**15..2**15-1;
for INT_16'Size use 16; -- Représenter le type sur 16 bits
De plus, on a le choix entre les types signés, comme dans les déclarations ci-dessus et les types modulaires. On préférera les premiers en général, car ils offrent plus de sécurité (notamment en cas de débordement). On utilisera les types modulaires lorsque l'on a effectivement besoin d'une arithmétique circulaire, ou pour un type de très bas niveau, car ils permettent des manipulations supplémentaires au niveau du bit. Enfin il existe quelques cas où l'on doit utiliser Integer:
type Index is new Integer;
type Liste is array (Index range <>) of Angle;
Types réels.
De même que l'on n'utilise pas directement le type Integer en Ada, on ne saurait utiliser Float pour tous les calculs «réels». Ada offre toute une palette de types numériques et il faut choisir le plus approprié au problème à résoudre. Voici quelques grandes lignes pour orienter ce choix:
type Position is delta 1.0/50.0 range 0.0 .. 1.0;
for Position'Small use 1.0/50.0;
type Coordonnée is new Float;
type Coordonnée_précise is new Long_Float;
Dans d'autres cas, la précision minimale est obligatoire. Si l'on calcule par exemple l'angle de rentrée d'une navette dans l'atmosphère, la précision demandée est vitale: trop à plat, la navette rebondira sur les couches hautes, trop inclinée elle brûlera. Une analyse fine peut conduire à la conclusion que pour obtenir une précision de 10-3 sur le résultat, les données doivent être représentées avec une précision de 10-7. On déclarera donc:
type Donnée is digits 7;
On voit qu'Ada offre toute une palette de possibilités dans le choix des types numériques; on évitera de se précipiter sur le type Float comme on le ferait dans d'autres langages.
Chaînes de caractères.
La notion de chaîne de caractères est moins évidente qu'il n'y paraît. Là encore, une analyse fine des besoins amène à reconnaître plusieurs modes d'utilisation des chaînes de caractères, avec des contraintes différentes. C'est pourquoi Ada offre plusieurs modèles pour répondre aux différentes utilisations; noter que les différents paquetages correspondant à ces possibilités offrent des sous-programmes de manipulation identiques (grâce à la surcharge) et que l'utilisateur n'a donc à apprendre qu'un seul mode d'emploi. Voici ces différents modèles.
Max_Nom : constant := 15;
package Opérations_Nom is new
 Ada.Strings.Bounded.Generic_Bounded_Length(Max_Nom);
subtype Nom is Opérations_Nom.Bounded_String;
Nous avons fourni le sous-type Nom pour exporter un identificateur significatif pour l'utilisateur. Ceci ne rend pas les opérations définies dans le paquetage directement visibles; mais ces opérations ne sont utiles qu'aux personnes effectuant des recherches, modifications, etc., sur des noms. En appelant l'instanciation Operations_Nom, cela permet à ces utilisateurs de marquer les endroits où ils travaillent effectivement sur des noms grâce à une clause use Opérations_Nom.
Enfin, il se peut que d'autres besoins conduisent à la définition d'un paquetage «maison» de chaînes de caractères, avec des caractéristiques différentes. Au nom de l'uniformité et de la diminution de la complexité, on veillera à fournir les mêmes fonctionnalités, avec les mêmes noms, que dans les paquetages faisant partie de l'environnement standard.
Types articles.
On utilisera des types articles chaque fois qu'une entité est composée de plusieurs autres éléments. Le principal choix (non évident) est de décider si l'on doit utiliser un type étiqueté ou un type article à discriminants lorsque l'on veut rassembler dans un même type (ou famille de types) des valeurs polymorphes, c'est-à-dire ne comportant pas toutes les mêmes éléments.
Dans un type à discriminant, les différentes variantes sont définies statiquement lors de la déclaration initiale du type; l'ajout par la suite de structures non prévues au départ nécessite des changements importants dans l'ensemble du logiciel. En revanche, ce même aspect statique garantit un maximum de contrôle. Il n'est pas possible d'effectuer des modifications qui remettraient en cause la validité d'une partie de logiciel déjà validée. Enfin, il est possible (si le type l'a autorisé) de déclarer des variables non contraintes, qui peuvent donc contenir différentes variantes au cours du temps.
Les type étiquetés offrent un maximum de souplesse; ils peuvent être enrichis après coup sans toucher au type d'origine. En revanche, ces modifications peuvent affecter (via le mécanisme de liaison dynamique) le comportement des modules déjà écrits. La liaison dynamique, de par son principe même, peut empêcher toute certification exhaustive des modules. Enfin, les objets se voient affecter un type effectif au moment de leur élaboration et ne peuvent en changer par la suite.
Conceptuellement, on préférera les types à discriminants si les différentes variantes constituent de simples paramètres, ou des options, d'un seul type. En particulier, si ces options sont susceptibles d'évoluer au cours du temps, il faudra utiliser des variables non contraintes qui ne sont possibles qu'avec les types à discriminants. On préférera les types étiquetés si l'on pense avoir affaire à des types différents, mais appartenant à une même famille dont on veut exprimer dans le langage la nature commune. Ces principes peuvent être pondérés par des considérations pragmatiques: là où la validation et la sécurité sont prédominants, les types à discriminants sont préférables. Inversement, pour des logiciels à moindres contraintes sécuritaires, mais dont on prévoit une évolution plus importante dans le temps, les types étiquetés apportent plus de souplesse.
Enfin, ces deux options ne sont pas exclusives: il est possible d'avoir des types étiquetés à discriminants. Il sera rare de les utiliser pour des parties variables, mais il est tout à fait raisonnable de définir des types étiquetés dont certains éléments, des tables par exemple, ont une taille paramétrable au moyen de discriminants.
Pointeurs.
Les pointeurs sont un outil indispensable dans certains cas, mais l'expérience montre qu'ils sont une cause importante de difficultés. Celles-ci peuvent être causées d'une part par le fait de travailler sur des références, plus difficiles à appréhender par le programmeur que les variables directes (le programmeur doit gérer dans sa tête le niveau d'indirection supplémentaire); d'autre part, le mécanisme d'allocation et surtout de désallocation dynamique est difficile à gérer: démontrer l'absence de «fuites de mémoire» (variables allouées et jamais désallouées) est techniquement si ardu que de nombreux systèmes temps réel devant fonctionner en permanence préfèrent interdire l'utilisation de toute allocation dynamique. L'expérience nous a amené à formuler la «loi» suivante:
On utilise moins les pointeurs en Ada que dans d'autres langages, car on dispose d'autres moyens pour réaliser certaines structures. Notons en particulier que:
Inversement, un cas exige des pointeurs et de l'allocation dynamique: la construction de structures de données liées et dynamiques: arbres, listes, etc., et plus généralement lorsque l'on souhaite définir un type de donnée abstrait à sémantique de référence (rappelons cependant que ce n'est pas la seule solution possible). 
On utilise aussi parfois des pointeurs pour éviter de copier des données volumineuses: imaginons une table de symboles par exemple. Il serait très pénalisant de renvoyer toute l'information associée à un symbole, surtout si l'utilisateur n'a besoin que d'une information spécifique. Il est donc préférable de renvoyer une référence sur l'entrée dans la table. Dans d'autres langages, ceci a une conséquence fâcheuse: l'utilisateur peut modifier le contenu de la table en utilisant directement la référence. C'est pourquoi Ada fournit des pointeurs sur constantes: de tels pointeurs peuvent désigner aussi bien des variables que des constantes, mais ne peuvent être utilisés pour modifier l'objet désigné. Une table des symboles pourrait ainsi s'écrire:
package Table_des_Symboles is
 type Information is
 record
 Info_1 : ...;
 Info_2 : ...;
 etc...
 end record;
 type P_Information is access constant Information;
 type Clé is new String;
 procedure Ajouter (Nom : Clé; Info : Information);
 function L_Information (De : Clé) return P_Information;
end Table_des_Symboles;
Côté utilisateur on aura:
Ajouter ("Rosen", ...);
if L_Information (De => "Rosen").Info_1 = ... --OK
L_Information (De => "Rosen").Info_1 := ...; -- ERREUR !
On peut donc garantir l'intégrité de la table, tout en renvoyant des références sur les structures internes. Notons que certaines formes apparentées à des pointeurs (paramètres accès, autopointeurs, attribut 'Access) permettent d'établir des références et de créer des entités logiquement reliées sans nécessiter d'allocation dynamique; les règles du langage garantissent l'impossibilité de conserver des pointeurs sur des entités ayant disparu.
On limitera donc l'utilisation des pointeurs et plus spécialement de l'allocation dynamique, aux seuls cas où elle est réellement indispensable. On fera usage si possible des autres possibilités et on prendra garde de ne pas se laisser entraîner à mettre des pointeurs partout par mimétisme avec des langages ne disposant pas de structures de données aussi puissantes que celles d'Ada.
Utilisation de Unchecked_Conversion.
Ayant abondamment vanté les mérites du typage fort, on peut se demander pourquoi Ada autorise le détypage au moyen de Unchecked_Conversion. En fait, il s'agit d'une fonctionnalité indispensable lors de changements de niveaux d'abstraction, qui imposent de voir la même donnée selon deux vues différentes. Prenons un exemple.
Un nombre flottant est considéré, dans la plupart des applications, comme une entité autonome. Cependant, certains algorithmes numériques fins nécessitent d'accéder séparément à la mantisse ou à l'exposant du nombre. Il convient donc de fournir deux vues du même type: soit comme un tout, soit comme une entité composite comprenant signe, mantisse et exposant. Nous pouvons fournir ce service de la façon suivante:
type Intervalle_Mantisse is range 0 .. 2**24;
type Intervalle_Exposant is range -64..63;
type Flottant_Composite is
 record
 Négatif : Boolean;
 Exposant : Intervalle_Exposant;
 Mantisse : Intervalle_Mantisse;
 end record;
-- Clauses de représentation
for Flottant_Composite'ALIGNMENT use 4;
for Flottant_Composite use
 record
 Négatif at 0 range 0..0;
 Exposant at 0 range 1..7;
 Mantisse at 1 range 0..23;
 end record;
function Vers_composite is new
 Unchecked_Conversion (Float, Flottant_composite);
function Exposant (Nombre : Float)
 return Intervalle_Exposant is
begin
 return Vers_composite (Nombre).Exposant;
end Exposant;
Remarquez le processus: on décrit la vue que l'on veut obtenir (le type article) et sa représentation interne (les clauses de représentation). Ensuite, Unchecked_Conversion nous permet de voir la même donnée selon l'une ou l'autre vue. À partir de là, la fonction Exposant n'a plus qu'à retourner la partie exposant d'un flottant considéré comme un article. Noter au passage l'aspect descriptif de cette façon de faire: c'est le compilateur qui devra se débrouiller pour gérer les masques et les décalages nécessaires à extraire l'information... ce qui n'est pas un petit service rendu au programmeur qui n'a plus à se soucier de cette «cuisine».
Notons enfin qu'il existait un cas fréquent d'utilisation de Unchecked_Conversion qui a disparu avec Ada 95: celui où l'on devait voir des données de haut niveau comme des suites d'octets, pour stockage sur disque ou envoi sur un réseau par exemple. Les nouveaux attributs 'Read et 'Write fournissent désormais cette conversion de vue; citons également l'attribut 'Valid qui permet de vérifier qu'une valeur en provenance de l'extérieur (entrée-sortie, autre langage) est bien conforme aux exigences du typage Ada, ainsi que le paquetage Ada.Storage_IO qui effectue des pseudo-entrées-sorties vers des tableaux d'octets.
Conclusion sur l'utilisation des types.
Choisir le bon type pour représenter une entité n'est pas chose aisée: entre le désir de modéliser de façon précise les entités du monde réel, la nécessité d'éviter une prolifération abusive des types, les contraintes d'évolutivité et d'efficacité, les solutions sont nombreuses et il faut rechercher le meilleur compromis. Il est donc important de prendre le temps de réfléchir soigneusement avant de définir un type. Eventuellement, l'utilisation du paquetage ADPT et des types associés permet de poursuivre le projet en différant la décision jusqu'à un moment où le problème et les contraintes éventuelles seront mieux connus. Il ne faudrait cependant pas en déduire qu'Ada crée de nouvelles difficultés; bien sûr, dans les langages où le seul type entier est Integer, le problème du choix d'un type entier ne se pose pas! Il n'empêche que le problème de la définition du type le plus approprié existe toujours. Simplement, ces autres langages n'offrent aucun outil pour le résoudre et il reste entièrement à la charge du programmeur.
Choix liés aux génériques.
Faut-il ou non utiliser des génériques? Encore un point où l'on se pose des questions avec Ada que l'on ne se posait pas avant... puisque la fonctionnalité n'existait pas. Et pourtant, les génériques sont bien utiles, puisqu'ils ont été adoptés (sous des formes plus ou moins différentes) par des langages comme Eiffel et C++. Les points à considérer sont d'ordre économique, méthodologique et de performance.
Point de vue économique.
Toutes choses égales d'ailleurs, le développement d'un générique prend plus de temps et d'effort que son équivalent non générique... tant qu'il n'y en a qu'un. Ce surcoût est rapidement amorti dès que le générique est utilisé plusieurs fois. Inversement, il est moins coûteux d'écrire directement un générique que de transformer par la suite une unité qui n'a pas été prévue pour. Voici quelques règles empiriques pour guider ce choix:
Point de vue des performances.
On a souvent peur de la duplication de code engendrée par l'utilisation des génériques; mais si l'on écrit explicitement deux fois la même chose, il y a certainement duplication, alors que certains compilateurs sont capables de partager du code entre instanciations. Autrement dit, ce n'est pas l'utilisation du générique qui augmente la taille du code, c'est le fait d'avoir deux modules voisins et l'utilisation d'un générique ne peut être pire (mais peut être plus économique) que d'écrire deux fois le module. De plus, le risque de duplication peut être considérablement réduit par une découpe judicieuse de la structure. Retenons une règle générale:
En particulier, si des sous-programmes sont nécessaires pour la réalisation de l'unité générique, mais qu'ils ne dépendent pas eux-mêmes des paramètres génériques, on aura intérêt à les regrouper dans des paquetages (non génériques) externes. Le générique se trouvera être en fait le point d'entrée d'un petit sous-système. Dans certains cas, on peut aboutir à une structure où la partie générique n'est plus qu'une «peau», généralement destinée à préserver le typage Ada. Supposons par exemple que nous voulions faire une interface de boîte aux lettres. Nous voulons bien sûr respecter le typage Ada, nous fournissons donc une interface:
generic
 type Type_Message is private;
package Boîte_Aux_Lettres is
 procedure Emettre (Message : in Type_Message);
 procedure Recevoir (Message : out Type_Message);
end Boîte_Aux_Lettres;
En fait le traitement est totalement indépendant du type de données, puisque pour les besoins de la boîte aux lettres, l'élément à envoyer n'est qu'une suite d'octets... Nous définissons donc le paquetage (non générique) suivant, qui fera l'essentiel du travail:
with System.Storage_Elements;
package Messagerie_Bas_Niveau is
 use System.Storage_Elements;
 procedure Emettre (Depuis : in Storage_Array);
 procedure Recevoir (Dans : out Storage_Array);
end Messagerie_Bas_Niveau;
Le corps générique va utiliser le paquetage Ada.Storage_IO pour convertir le type de haut niveau en une suite d'octets, puis appeler les fonctions de Messagerie_Bas_Niveau:
with Messagerie_Bas_Niveau, Ada.Storage_IO; 
package body Boîte_Aux_Lettres is
 package Message_IO is new Ada.Storage_IO (Type_Message);
 use Messagerie_Bas_Niveau, Message_IO;
 Tampon : Buffer_Type;
 procedure Emettre (Message : in Type_Message) is
 begin
 Write (Tampon, Message);
 Emettre (Tampon);
 end Emettre;
 procedure Recevoir (Message : out Type_Message) is
 begin
 Recevoir (Tampon);
 Read (Tampon, Message);
 end Recevoir;
end Boîte_Aux_Lettres;
Pourquoi ne pas mettre directement Messagerie_Bas_Niveau à la disposition de l'utilisateur? Précisément parce qu'à ce niveau, toute vérification de type a disparu. Si c'est nécessaire pour l'implémentation, il est toujours préférable de le cacher à l'utilisateur.
Noter qu'en Ada 83, il n'existait pas de moyen de cacher physiquement le paquetage Messagerie_Bas_Niveau et que l'on n'avait donc pas de garantie que l'utilisateur n'y avait pas accès, à moins de tout rassembler en un seul paquetage. En Ada 95, le mécanisme de bibliothèques hiérarchiques nous permet ce contrôle tout en bénéficiant de compilations séparées. Une meilleure organisation est donc la suivante:
package Boîtes_Aux_Lettres is
end Boîtes_Aux_Lettres;
generic
 type Type_Message is private;
package Boîtes_Aux_Lettres.Une_Boite is
 procedure Emettre (Message : in Type_Message);
 procedure Recevoir (Message : out Type_Message);
end Boîtes_Aux_Lettres.Une_Boite;
with System.Storage_Elements;
private package Boîtes_Aux_Lettres.Messagerie_Bas_Niveau is
 use System.Storage_Elements;
 procedure Emettre (Depuis : in Storage_Array);
 procedure Recevoir (Dans : out Storage_Array);
end Boîtes_Aux_Lettres.Messagerie_Bas_Niveau;
Le paquetage Boîtes_Aux_Lettres ne sert plus que d'enveloppe; Une_Boîte est un enfant public, utilisable par tout le monde, mais Messagerie_Bas_Niveau est un enfant privé, appelable uniquement depuis Une_Boîte. Les propriétés de fermeture du sous-système sont donc garanties. Le paquetage général Boîtes_Aux_Lettres est quand même nécessaire, car un enfant de générique ne peut être que générique; Messagerie_Bas_Niveau ne pourrait donc être un enfant (même privé) de Une_Boîte.
En termes de vitesse d'exécution, l'utilisation de génériques ne devrait pas provoquer de pénalité, sauf dans le cas de génériques partagés; mais c'est alors un effet de la règle habituelle que les gains en espace mémoire se paient en temps d'exécution. Certains compilateurs disposent d'ailleurs d'options permettant à l'utilisateur de choisir entre partage et duplication.
Choix liés au parallélisme.
Lorsque l'analyse du système conduit à la définition de processus parallèles, on retrouvera naturellement des tâches correspondantes au niveau de l'implémentation. Ceci dit, l'usage des tâches n'est pas limité à ces tâches structurelles: elles peuvent se révéler un excellent moyen de structuration. Inversement, l'enthousiasme né de la disponibilité de cet outil qui n'existe que dans peu d'autres langages peut conduire à des abus: il faut donc définir les conditions où l'usage de tâches peut être bénéfique.
Principes généraux.
Le modèle Ada des tâches en fait fondamentalement des serveurs, fournissant par leurs entrées des services à leurs clients. En général, l'état normal d'un serveur sera l'état bloqué, en attente de demande de service; lors de la réception d'une demande, le serveur deviendra actif, provoquant au besoin le réveil d'autres serveurs, puis au bout d'un certain temps l'ensemble tendra à retourner vers l'état bloqué. On peut donc voir l'ensemble des tâches comme une suite d'engrenages, qu'un mouvement de balancier vient mettre en mouvement, puis qui retourne à son état stable... jusqu'au prochain évènement.
De telles tâches sont généralement peu coûteuses pour le système: une tâche à l'état bloqué ne prend aucune puissance de calcul. En revanche, elle occupe un certain espace mémoire, au moins l'espace de sa pile.
Inversement, les tâches actives, c'est-à-dire celles qui «tournent» sans se bloquer, consomment de la puissance de calcul inutilement. On en a besoin par exemple pour faire des scrutations (polling) de périphériques qui ne disposent pas de possibilités d'interruptions: le seul moyen de savoir qu'une donnée est arrivée est alors d'aller les interroger périodiquement. On veillera cependant à insérer un delay (même faible) dans la boucle pour éviter que la tâche n'accapare l'unité centrale sur un monoprocesseur.
On pourra enfin avoir une, et au plus une, tâche en boucle réellement continue; ce sera par exemple la boucle principale d'un exécutif périodique. On peut en autoriser une dans un système temps réel, car il faut bien «occuper l'UC» quand le système ne traite aucun événement; on ne peut en autoriser qu'une, car en l'absence de suspension (ou de partage de temps) sur un monoprocesseur, une autre tâche en boucle permanente ne prendrait jamais la main.
Les tâches constituent un outil très commode et, sauf contrainte particulière, il ne faut pas chercher à les éviter délibérément. Inversement, certains ont parfois tendance à en mettre partout. Les remarques que nous avons faites sur le minimum de complexité s'appliquent à la décomposition en tâches: un petit nombre de tâches fait décroître la complexité, un trop grand nombre fait perdre ce bénéfice à cause de la multiplication des communications. Il importe donc de choisir une granularité raisonnable, entre les deux extrêmes que sont la tâche unique qui fait tout et une tâche par service. En particulier, lorsque plusieurs services sont logiquement reliés, il est préférable de les faire traiter par une seule tâche, même si cela provoque de petites sérialisations.
Tâches comme unités de structuration.
On peut être parfois amené à utiliser des tâches comme unités de structuration, indépendamment de tout aspect «parallélisme». En fait, il est assez naturel d'utiliser des tâches (au sens Ada) pour modéliser des «tâches à accomplir» qui n'ont pas de rapports dans le temps. Imaginons par exemple que nous devions remplir deux formulaires A et B. Ceux-ci comportent des calculs et il faut parfois reporter des calculs intermédiaires d'une feuille sur l'autre pour pouvoir poursuivre (Figure 36). Il est bien sûr possible d'écrire un programme séquentiel effectuant les calculs; mais il faudra déterminer soigneusement dans quel ordre s'occuper par moments des calculs de A et par moments des calculs de B. Il est de plus impossible d'obtenir une structure «objet», avec des modules distincts pour représenter chacune des feuilles.
Figure 36: Feuilles de calcul couplées
C'est possible en revanche si l'on représente chacune des feuilles par une tâche qui effectue les calculs nécessaires, qui envoie à l'autre les résultats intermédiaires et qui se met en attente lorsqu'elle a besoin de données en provenance de l'autre feuille. Le programmeur n'a plus à se soucier de l'ordre exact des calculs: il résultera de l'interaction entre les tâches. On voit qu'ici, l'utilisation des tâches apporte des améliorations d'ordre structurel (un objet du programme par objet du monde réel) et fonctionnel (plus besoin de se préoccuper des dépendances temporelles). Le parallélisme ne joue aucun rôle.
Synchronisations et communications: rendez-vous ou objets protégés?
En Ada 83, le rendez-vous était le seul moyen de synchronisation et de communication. Ada 95 a rajouté à celui-ci les objets protégés. Il ne faudrait surtout pas croire que ceux-ci remplacent les rendez-vous: ils comblent plutôt certains besoins.
Le rendez-vous est un mécanisme de haut niveau. Facile à comprendre, il modélise bien les interactions entre objets du monde réel. C'est cependant un mécanisme riche; de nombreux besoins réclament un mécanisme plus fruste. Par exemple, il est facile d'utiliser une tâche et des rendez-vous pour contrôler l'accès à une variable partagée par plusieurs tâches. C'est cependant un mécanisme lourd par rapport à la nature du problème et qui peut conduire à une multiplication abusive des tâches s'il y a beaucoup de variables partagées. De même, pour réaliser par exemple des objets gardés, il est nécessaire de fournir des verrous de bas niveau, de type «sémaphores». Bien qu'il soit possible de les réaliser au moyen de tâches, cela conduit à des inversions d'abstractions: implémentation d'outils de bas niveau au moyen d'outils de plus haut niveau. Les objets protégés ont donc été rajoutés pour satisfaire ce besoin: fournir un moyen de synchronisation de bas niveau, très efficace, mais aux possibilités restreintes. Bien que syntaxiquement rien ne l'y oblige, il est clair que le corps d'une opération protégée ne doit pas faire plus en pratique que changer l'état de quelques variables.
Si les inversions d'abstraction sont fâcheuses, il ne faudrait pas pour autant tomber dans le travers inverse: tout réaliser au moyen d'outils élémentaires, alors que l'on dispose d'outils de plus haut niveau. On réservera donc l'emploi des objets protégés aux mécanismes simples, rapides et de bas niveau: protection de variable, barrière, sémaphore... Il s'agira en général d'abstractions d'objets informatiques. On utilisera plutôt les rendez-vous pour les communications de haut niveau entre tâches appartenant au domaine de problème.
Choix liés aux situations exceptionnelles.
Au niveau de la politique de projet, nous avons décidé d'une politique de gestion des erreurs. Mais toutes les situations exceptionnelles ne sont pas nécessairement des erreurs. Il convient donc de décider individuellement, pour chaque sous-programme, quoi faire dans l'éventualité d'une situation exceptionnelle ne permettant pas de fournir le service demandé. Les différents points à considérer sont les suivants [Goo88]:
Conclusion.
Tout au long de cette quatrième partie, nous avons insisté sur l'importance de faire des choix rationnels à tous les niveaux du développement. Bien que ceci puisse paraître contraignant, nous terminerons en rappelant que cette démarche est avant tout rentable. Ne rien laisser au hasard conduit à des logiciels mieux construits, plus portables, plus évolutifs, plus sûrs et en un mot de meilleure qualité. Le génie logiciel est avant tout un état d'esprit; il nécessite des méthodes, des outils de haut niveau – en particulier un langage adapté – mais la clé permettant le développement de logiciels toujours plus complexes se situe avant tout dans la tête des développeurs