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

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.

Organisation de projet et choix fondamentaux

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 modifier

Les rôles à remplir modifier

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:

  • Un chef de projet, qui doit coordonner l'équipe et à qui reviendra la responsabilité de trancher lorsque plusieurs choix techniques sont possibles et que l'unanimité ne peut être obtenue parmi les concepteurs.
  • Un responsable financier, qui suivra l'état d'avancement du projet et l'évolution des dépenses par rapport au planning prévu (qu'il aura lui-même établi).
  • Un gestionnaire de configuration, chargé de la réception des modules, de leur intégration, et de l'historique des différentes versions ainsi que de leur archivage.
  • Un responsable documentation, pour suivre la production de la documentation, s'assurer de sa compréhensibilité et de son exactitude. Il sera également chargé de la rédaction de la documentation «utilisateur» du produit.
  • Un responsable qualité, qui doit définir les critères de qualité du projet et vérifier leur bonne application.
  • Un responsable composants logiciels, chargé aussi bien de mettre à disposition des autres membres les composants qui peuvent leur être nécessaires que d'identifier les éléments susceptibles de devenir des composants logiciels réutilisables par la suite.
  • Un responsable communication qui assurera la bonne circulation de l'information dans l'équipe, qui tiendra à jour les comptes rendus des réunions, et auprès duquel les autres membres devront retrouver les informations dont ils auront besoin.
  • Et bien entendu les développeurs proprement dits, chargés de la réalisation informatique du projet.

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 modifier

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 modifier

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 modifier

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 modifier

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[1]. 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.

  1. Le seul critère qu'il soit bon d'ignorer. Hélas, il est parfois plus important dans certaines décisions techniques qu'on ne pourrait le croire.

Programmation structurée modifier

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 modifier

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 modifier

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 modifier

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 modifier

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 modifier

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:

  • La méthode n'est pas bien comprise. Elle exige par exemple le remplissage de documents de conception dont le concepteur ne perçoit pas toujours l'utilité. On court alors le risque que les documents soient remplis machinalement, parce que le chef l'exige, mais ils seront alors de peu d'aide pour les programmeurs de maintenance qui les reliront par la suite. La solution à ce problème réside bien entendu dans une formation adéquate. Il peut arriver également que le chef ait «trop» bien compris la méthode, et insiste sur son application au-delà du raisonnable et aux dépens des autres aspects du projet.
  • La méthode ne correspond pas aux besoins. Une méthode orientée plutôt temps réel mettra l'accent sur les propriétés temporelles, alors que les méthodes orientées gestion accordent plus d'importance aux données. Une méthode mal choisie va donc focaliser l'attention du programmeur sur des points qui ne correspondent pas à ses préoccupations principales. Une méthode trop lourde par rapport à la taille du projet sera également difficilement acceptée, mais inversement une méthode insuffisante pour un projet complexe risque de mettre en danger la faisabilité même du programme.
  • L'utilité de la méthode n'est pas perçue. En particulier, les programmeurs qui ont eu l'habitude de travailler sans méthode bien définie, dans des petites équipes (quand ce ne sont pas des programmeurs individuels), ont du mal à se plier à la démarche contraignante d'une méthode formalisée[1]. Il convient donc de leur en faire sentir la nécessité, par exemple en les faisant travailler quelque temps à la maintenance d'un projet existant.
  • La méthode est mal utilisée. Les gens formés à une ancienne méthode tendent à propager leurs habitudes précédentes dans la nouvelle[2]. Il est fréquent de rencontrer ainsi des dérives fonctionnelles dans des projets censés être orientés objet. Cela provoque des problèmes de conception, ou rend les outils incapables de fournir le service escompté. Là encore, une formation appropriée est le meilleur antidote.

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.

  1. C'est souvent le cas des débutants frais émoulus de l'université.
  2. C'est également vrai des langages de programmation. On dit qu'un bon programmeur FORTRAN est capable d'écrire du FORTRAN dans n'importe quel langage...

Exercices modifier

  1. Quelle méthode serait la plus appropriée pour développer des interfaces graphiques? Un logiciel de transactions bancaires? Un programme de simulation de physique nucléaire? Défendez vos réponses.
  2. Le choix d'une méthodologie ressemble au choix de composants logiciels. Appliquer le raisonnement du paragraphe Identification à la décision de choisir une méthodologie existante ou, au contraire, d'en définir une nouvelle.

Politiques de projet modifier

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 modifier

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[1], 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:

Moi: Pourquoi avez-vous choisi C++ pour ce développement?
Lui: Parce que c'est ce que M. Xxx avait mis dans la proposition.
Moi: Et pourquoi M. Xxx l'avait-il mis dans la proposition?
Lui: ??? Oh, il l'avait mis dans la proposition...

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!

  1. Le langage Ada, vous n'aviez pas remarqué ?

Le langage principal modifier

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[1].

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.

  1. Des systèmes comme VMS, ou des environnements de compilation comme GCC mélangent très facilement les langages. Seul UNIX privilégie abusivement un langage (C) au détriment des autres.
  1. Logiciels jetables
  2. Ce terme désigne de façon imagée les logiciels que l'on utilise une fois, mais qui n'offrent plus d'intérêt une fois le résultat obtenu: on les appelle parfois logiciels «Kleenex», puisqu'on les jette après un seul usage. Pour de tels logiciels, la facilité et la rapidité de développement priment sur la maintenance (par définition, il n'y en a pas). La rapidité d'exécution est également sans importance, car le logiciel n'est exécuté qu'une fois, ainsi que la lisibilité puisque le programme ne sera jamais relu que par son auteur, et uniquement pendant le développement. Tout ceci désigne des langages interprétés, très interactifs, tels que APL dans le domaine des calculs mathématiques, ou SmallTalk pour maquetter des interfaces utilisateur. C'est également le cas des maquettes rapides, dont nous rappellerons qu'elles doivent être jetées après usage: le risque est que l'auteur soit tenté de conserver le logiciel, puis de le développer, et l'on termine avec un logiciel devant être maintenu alors que rien dans la structure initiale (ni dans le choix du langage) n'a été prévu pour cela. Si un logiciel jetable doit donner naissance à un logiciel plus complet, il est plus rentable de le recoder entièrement en fonction des nouvelles exigences que d'essayer d'adapter l'existant! Lorsqu'il s'agit d'explorer un domaine nouveau, il est donc raisonnable d'écrire une première version dans un langage adapté au développement rapide[1].
    1. Et donc fondé sur des principes opposés à ceux d'Ada : rapidité d'écriture plutôt que facilité de lecture, aucune considération pour la maintenance ni pour l'efficacité...
  3. Domaines très spécialisés
  4. Certains langages ont été conçus spécifiquement pour certains domaines: c'est le cas de Lisp en intelligence artificielle, de Prolog pour les systèmes experts et de SQL pour les bases de données. Si l'application concerne massivement un de ces domaines, le langage spécialisé sera certainement le plus performant. D'ailleurs, coder une application typiquement «lispique» en Ada reviendrait quasiment à récrire un interpréteur Lisp en Ada (ce qui d'ailleurs se fait très bien, mais pourquoi refaire ce qui a déjà été fait?). Attention cependant: ces langages très performants dans leur domaine deviennent souvent catastrophiques dès qu'ils en sortent. Écrire une interface utilisateur correcte en Prolog relève de l'acrobatie ou de l'exercice de style! Il est donc particulièrement important avec ces langages de sous-traiter les éléments qui n'appartiennent pas à leur domaine à des modules écrits dans d'autres langages.
  5. Utilisation d'éléments ou d'outils existants
  6. Parfois, on prescrit l'utilisation d'outils (générateurs d'interface, vérificateurs formels...) qui ne savent traiter qu'un seul langage, qui se trouve donc imposé d'office. Il arrive même que le client prescrive son langage pour des raisons qui lui sont propres. Le développeur n'a plus aucun choix, encore qu'il puisse parfois discuter de ces contraintes avec son client s'il estime que le choix imposé est de nature à mettre en péril la réussite du projet. Noter que la contrainte imposée par le DoD (et de plus en plus affirmée) sur ses fournisseurs d'utiliser Ada est de cette nature. Mais elle est renforcée par le fait que les études ont montré le bien-fondé de cette directive en termes de maintenance à long terme. De façon plus discutable, le projet peut être amené à choisir un langage pour des raisons d'utilisation de composants logiciels écrits dans le langage considéré. Si l'utilisation de composants logiciels peut (et parfois doit) avoir un impact sur la structure du projet, il est possible d'écrire des interfaces permettant de récupérer des bibliothèques écrites dans d'autres langages. Notons qu'Ada 95 a fait un effort tout particulier pour faciliter l'utilisation de modules écrits dans d'autres langages, notamment en C. Il faut donc évaluer le gain provenant de l'interfaçage direct avec les composants contre le coût du choix d'un langage non nécessairement le plus adapté au projet lui-même. En particulier, compte tenu de l'existence d'excellentes bibliothèques d'interfaçage et d'un standard Ada/POSIX[1], le fait de développer sous UNIX n'est certainement pas une raison suffisante pour justifier l'utilisation du langage C!
    1. Ainsi que d'interfaces Ada/X Window/Motif, Ada/CORBA/IDL...

Cohabitation de plusieurs langages modifier

Il existe des cas où il est raisonnable d'utiliser plusieurs langages pour un même développement logiciel. Citons par exemple:

  • Reprise de bibliothèques ou de composants logiciels existants, développés dans d'autres langages. En particulier, il se peut que certains algorithmes critiques de sécurité aient été validés (souvent au moyen de méthodes formelles). On préférera alors les réutiliser plutôt que de les récrire et de devoir passer par toutes les phases de certification, toujours longues et coûteuses.
  • Présence d'algorithmes relevant d'une technique particulière: interfaçage avec des bases de données, raisonnement formel ou déductif.

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 pragma Import remplace le pragma Interface d'Ada 83. Son principal avantage est de s'appliquer non seulement à des sous-programmes, mais aussi à d'autres entités: variables, constantes, classes... Le pragma Export permet symétriquement de mettre des entités Ada à disposition d'autres langages.

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 modifier

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 modifier

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:

  • les détails de la présentation: indentation, retraits... Le plus simple est d'adopter la présentation du manuel de référence.
  • la convention d'utilisation des minuscules et des majuscules: généralement, on met les mots-clés en minuscules et les identificateurs avec une majuscule initiale. On peut décider de mettre les identificateurs globaux, ou tout au moins ceux reconnus comme particulièrement importants, entièrement en majuscules.
  • les conventions de nommage. Il est par exemple fréquent de nommer les types avec des identificateurs commençant par «T_», ou se terminant par «_type».
  • la décision d'utiliser (ou d'interdire) les clauses use. Ce point est l'objet d'un vif débat dans la communauté Ada, aussi y reviendrons-nous dans la partie concernant les règles de nommage.
  • la forme des commentaires et leurs normes d'utilisation: commentaires standardisés en tête ou en fin de module rappelant les grandes fonctionnalités, l'auteur, l'historique, etc.; commentaires utilisés pour repérer ou séparer les différents sous-programmes; placement des commentaires par rapport à la séquence de code à laquelle ils se rapportent.

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 modifier

Principes généraux modifier

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.

  1. Diagnostic
  2. Le diagnostic consiste à reconnaître qu'une situation exceptionnelle s'est produite. À l'origine du diagnostic, il y a toujours un test de condition logique, mais selon les cas ce test peut se produire à divers niveaux d'abstraction: niveau matériel pour une division par zéro, test généré par le compilateur (débordements de tableau par exemple), test programmé par l'utilisateur. Le diagnostic peut être interne, quand le sous-programme effectue lui-même le test, ou externe, lorsque l'on fournit à l'utilisateur une fonction d'interrogation permettant de savoir si le service pourra être rendu ou non. Par exemple, la fonction End_Of_File est un diagnostic externe permettant de savoir s'il reste encore des données dans un fichier, et donc s'il est possible d'effectuer une lecture. Si l'on effectue malgré tout une lecture alors que l'on est en bout de fichier, la procédure de lecture effectuera un test interne, qui aboutira à la levée de l'exception End_Error.
  3. Signalement
  4. Le signalement désigne le mécanisme utilisé, une fois qu'une condition exceptionnelle a été reconnue, pour avertir le client que le service demandé n'a pu être rendu. Il peut être synchrone si le client détermine lui-même le moment où la condition anormale lui est connue, asynchrone sinon. Les mécanismes synchrones présentent l'avantage que le client maîtrise mieux le déroulement de son programme; en revanche, ils créent le risque de l'omission du test de condition anormale, et donc de poursuivre le traitement sans s'apercevoir qu'un service n'a pu être rendu, en violation du principe:
    Il n'est qu'une chose pire qu'un programme qui se «plante»: un programme qui donne des résultats faux, mais vraisemblables.
    En cas d'appels imbriqués, un module ne peut rendre un service que si les fonctionnalités qu'il appelle se sont elles-mêmes terminées correctement: le composant diagnostiquera un problème, parce qu'un sous-programme appelé lui aura signalé une exception. La notion de diagnostic pour une couche correspond donc à la notion de signalement des couches plus profondes.
  5. Traitement
  6. Le traitement consiste à réagir de façon appropriée lorsqu'une condition exceptionnelle a été signalée. Le traitement est interne s'il s'effectue à l'intérieur du sous-programme appelé, externe s'il est à la charge de l'appelant. Dans certains cas, il peut y avoir à la fois un traitement interne systématique et un traitement externe à la charge de l'appelant. Que faire lorsqu'une condition exceptionnelle est diagnostiquée? Il faut d'abord déterminer s'il s'agit d'une erreur ou non. Si ce n'est pas une erreur, c'est donc une condition qui fait partie de l'algorithme; elle aura été prévue au niveau de la conception du cas général. Le cas le plus évident est celui de la lecture de données qui s'arrête en cas de fin de fichier. Cela peut être également le cas d'exceptions plus «violentes»: il existe par exemple des algorithmes numériques rapides, mais qui risquent de déborder dans certains cas limites, et d'autres plus lents, mais sûrs. Une solution consiste alors à lancer l'algorithme rapide, et s'il échoue, l'algorithme lent:
    begin
    	Algorithme_Rapide;
    exception
    	when Constraint_Error =>
    		Algorithme_Lent;
    end;
    

    Malgré son aspect «bestial», cette technique peut s'avérer statistiquement efficace.

    Lorsque l'exception constitue une erreur, le logiciel doit s'efforcer d'atteindre un état bien défini. Il existe quelques grandes «classes» de réactions, qui ne sont d'ailleurs pas exclusives:

  • Corriger. La cause de l'erreur est connue et le programme sait prendre les mesures appropriées.
  • Entrer en mode dégradé. La cause de l'erreur est connue mais la correction n'est pas possible. On effectue une correction partielle, qui réduit les fonctionnalités du logiciel, mais permet de poursuivre... ou de se terminer «proprement».
  • Informer. On envoie un message à l'utilisateur, et on abandonne totalement l'opération demandée. C'est souvent ce que l'on fait dans les boucles les plus externes des programmes interactifs. Des variantes sont possibles, comme de demander à l'utilisateur une intervention externe (déprotéger la disquette...), lui proposer de réessayer, etc.
  • Consigner. On enregistre dans un fichier, un listing ou tout autre support l'apparition de l'erreur pour analyse ultérieure.
  • Propager. L'erreur empêche de poursuivre normalement: elle constitue donc un diagnostic, que nous devons signaler aux couches appelantes. La propagation est la réaction normale lorsqu'un incident dépasse les capacités de réaction de la couche qui la reçoit (je ne sais plus quoi faire, donc je repasse le problème au client).
  • Politiques
  • Une politique de gestion d'erreurs consiste à définir précisément ce qui constitue une erreur, et comment celle-ci doit être gérée (diagnostiquée, signalée, traitée) par le logiciel. Comme il existe plusieurs formes d'erreurs (de l'erreur utilisateur «normale» à l'erreur fatale empêchant la poursuite de l'exécution du programme), ainsi éventuellement que plusieurs modes de traitement selon les caractéristiques du module qui l'a détectée, il faut définir une politique de gestion d'erreur pour chaque forme caractéristique. Nous avons ainsi vu que la seule possibilité dans le cas d'un composant logiciel réutilisable est de lever une exception[1]. Dans les autres cas, il est souvent nécessaire d'adopter un comportement plus évolué. C'est l'ensemble de ces politiques particulières qui définit la politique d'erreur générale du projet.
    1. Ceci ne contredit pas ce que nous avons dit plus tôt sur la nécessité de définir une politique d'erreur : dans ce cas particulier, nous définissons une politique qui se réduit à la seule levée d'une exception.

    Politique de correction locale modifier

    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 modifier

    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[1], et ne peut être utilisée (en Ada) avec des fonctions.

    Les fonctions Ada ne peuvent avoir que des paramètres in. La raison de cette contrainte supplémentaire est purement méthodologique.

    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[2].

    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.

    1. Et alors ? pensera le lecteur (avec raison). Il se trouve que l'état d'esprit de ceux qui utilisent cette technique les conduit en général à minimiser le nombre de lignes écrites.
    2. Ce qui a amené la norme IEEE 1003.1c, définissant le parallélisme, à définir ERRNO comme une fonction, contredisant ainsi la norme 1003.1a qui ne définit que les aspects séquentiels...

    Politique du déroutement modifier

    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:

    ON ZERO_DIVIDE CALL MA_PROCEDURE;

    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 modifier

    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[1], 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.

    1. Exemple dont on a tellement abusé que l'on est en droit de se demander s'il est possible de travailler formellement sur autre chose que des piles.

    Politique par exceptions simples modifier

    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 modifier

    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 modifier

    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 modifier

    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
    Diagnostic Signalement Traitement
    Correction locale Interne Asynchrone Interne
    Code de retour Interne Synchrone Externe
    Déroutement Interne Asynchrone Interne
    Contrat Externe Synchrone Externe
    Exception simple Interne Asynchrone Externe
    Oméga Interne Synchrone Externe
    Gestion centralisée Interne Asynchrone Interne/Externe
    Figure 35: Les différentes politiques d'erreurs

    Exercices modifier

    1. Imaginer un cas (justifié) où le langage principal ne serait pas Ada, mais où Ada serait utilisé comme langage secondaire.
    2. Justifier les choix de présentation vérifiés automatiquement par le compilateur GNAT (voir la documentation fournie avec le compilateur).
    3. Estimer le coût relatif des différentes politiques de gestion d'erreur du point de vue des performances. Écrire ensuite des programmes de test et comparer les mesures aux prévisions.

    Les choix de la phase de réalisation modifier

    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 modifier

    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[1].

    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:

    Empiler l'ancien contexte (push the old context)

    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...

    1. ... ou presque. Si le compilateur a le droit de mettre une limite, celle-ci ne peut être inférieure à 200 caractères, ce qui est plus que suffisant !

    Considérations générales modifier

    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é[1] 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:

    • Lorsqu'un nom est formé de plusieurs «mots», utiliser le trait bas (caractère '_') pour séparer les mots. L'usage de majuscules dans ce but peut conduire à des ambiguïtés, comme ce fichier de nombres entiers dont le nom était FichierDentiers[2]... : En Ada, le trait bas est toujours autorisé dans les identificateurs. Dans beaucoup d'autres langages, il peut l'être ou non à la discrétion des compilateurs, ce qui interdit son usage dans les composants portables. Les utilisateurs de langages à syntaxe «C» (notamment Java) tendent à éviter l’utilisation du trait bas, alors même qu’il est autorisé par le langage.
    • Les procédures expriment des actions, et doivent être désignées par des verbes; les fonctions retournent des valeurs et doivent utiliser des noms caractéristiques de la valeur retournée, ou des formes verbales de la forme «Est_...» pour les fonctions à résultat booléen. De même, les variables doivent avoir des noms caractéristiques de la valeur contenue.
    • Et bien sûr, il faut éviter les abréviations.

    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:

    Un nom doit porter toute l'information nécessaire au lecteur pour comprendre sa signification et pas plus.

    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:

    Un nom doit exprimer la nature de l'entité désignée.

    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 :

    Bien qu'une des formes d'utilisation du nom puisse être préférable, toutes les autres doivent être acceptables.

    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.

    1. Nous utiliserons ce terme pour dénoter tout ce qui peut avoir un nom en Ada, plutôt que le terme «objet» qui a trop de significations.
    2. Cet exemple n'a pas été inventé pour le «gag» : il figure effectivement dans la norme Pascal.

    Utilisation de la surcharge modifier

    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:

    Les opérations spécifiques doivent porter des noms spécifiques; les opérations générales doivent utiliser des noms généraux.

    La question de la langue modifier

    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[1], 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 Nombre.Complexe 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.

    1. Un tel outil (Adasubst) est librement disponible depuis le site Adalog (http://www.adalog.fr)

    Discussion de l'exemple modifier

    Après avoir vu ces différentes règles, critiquons les différentes possibilités de notre premier exemple.

    • (1) se présente bien et est facile à lire. Mais il implique que le paquetage s'appelle Context, or le paquetage n'est pas lui-même le contexte, en violation de notre première règle. Le paquetage serait plutôt un gestionnaire de contexte (Context_Handler); mais écrire Context_Handler.Push(Old) détruirait l'expression «naturelle» du problème. De plus, si l'utilisateur n'emploie pas une notation complète, Push(Old) n'exprimerait pas toute l'information.
    • (2) et (3) ont bonne allure. Noter que si l'on utilise une notation complète, cela donnera Context_Handler.Push(Old_Context), ce qui est encore acceptable. L'argument en faveur de la surcharge jouera en faveur de (2).
    • (3) et (5) souffrent du même problème que (1): si l'utilisateur n'emploie pas la notation prévue par le concepteur, ils se réduisent à Push(Old).
    • (4) fait une hypothèse inadmissible sur l'utilisation de la procédure; rien ne dit que l'on souhaite empiler un «vieux» contexte. Si l'utilisateur veut empiler un contexte temporaire, la formulation deviendrait Push_Old_Context(Temporary), ce qui serait contradictoire.

    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 modifier

    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:

    • Choix du langage. L'anglais de préférence pour les composants commerciaux. Pour les composants d'entreprise, suivre la règle générale de l'entreprise.
    • Identifier la nature de l'entité que l'on nomme pour choisir un nom pertinent pour cette entité, indépendamment du contexte.
    • Identifier si l'opération est générale ou spécifique pour décider d'un nom unique ou tenter de réutiliser d'autres noms existants exprimant la même fonctionnalité sur des types différents.
    • Faire des essais de nommage avec les différentes possibilités (avec ou sans utilisation du nom complet, avec ou sans utilisation des paramètres nommés) pour vérifier qu'aucune combinaison n'est inacceptable.

    Utilisation de la clause «use» modifier

    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.

    Les entités définies dans la partie visible d'un paquetage ne peuvent être utilisées qu'en les préfixant avec le nom du paquetage. La clause use ouvre la visibilité et permet de les nommer directement. Ada 95 a ajouté la clause use type qui rend les opérateurs d'un type (tels que "+" et "-") visibles, sans ouvrir la visibilité aux autres éléments. Une clause use (ou use type) peut figurer dans n'importe quelle partie déclarative et n'a d'effet que pour la durée de vie de cette portée.

    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.

    1. Les raisons d'être de la clause «use»
    2. On aurait pu imaginer que le simple fait de mettre un with donne la visibilité directe[1]. Mais alors, on courrait le risque d'avoir trop d'identificateurs visibles simultanément et un souci constant dans la conception d'Ada a été de limiter et de contrôler l'espace des noms. C'est ce qui a donné naissance à ce mécanisme à deux niveaux: la clause with exprime les dépendances entre modules pris globalement: elle ne donne donc qu'une indication grossière de l'utilisation. La clause use, en n'ouvrant la visibilité directe que là où c'est nécessaire, permet de dire précisément où tel ou tel paquetage est effectivement utilisé. Imaginons par exemple un paquetage «type de donnée abstrait», fournissant un type et des opérations, dont des entrées-sorties:
      package Gestion_Couleurs is
      	type Couleurs is (Rouge, Bleu, Jaune, Blanc, Noir); 
      	function "+" (Left, Right : Couleurs) return Couleurs;
      	procedure Put (Item : Couleurs);
      end Gestion_Couleurs;
      

      Pour implémenter la procédure Put, il faut importer Text_IO au moyen d'une clause with sur le corps du paquetage. Cependant, aucune autre partie de ce corps n'en a besoin; ceci s'exprime naturellement en écrivant:

      with Text_IO;
      package body Gestion_Couleurs is
      	function "+" (Left, Right: Couleurs) return Couleurs is
      		...
      	end "+";
      	procedure Put (Item : Couleurs) is
      		use Text_IO;  -- il n'y a qu'ici qu'on l'utilise
      	begin
      		Put (Couleurs'Image (Item));
      	end Put;
      end Gestion_Couleurs;
      

      De cette façon, on documente pour le lecteur les endroits précis où l'on a besoin de Text_IO et l'on limite les visibilités inutiles. Comme d'habitude, le compilateur peut également mettre à profit ce supplément d'information pour effectuer des contrôles supplémentaires; par exemple, si à la suite d'un couper/coller malheureux une instruction Put se retrouve en dehors de la procédure, cela produira une erreur de compilation, puisque la procédure n'est visible directement que dans la portée du use.

      1. C'est ce qui se produit lorsque l'on importe une «unit» en Turbo-Pascal par exemple.
    3. Quelques bonnes raisons d'utiliser les noms complets
    4. Fort malencontreusement, dans beaucoup d'ouvrages (y compris le manuel de référence) les exemples de use portent globalement sur toute une unité de compilation, ce qui a conduit de nombreux programmeurs à assortir systématiquement les with du use correspondant. L'effet obtenu est exactement inverse de celui recherché par les concepteurs du langage: tout devient visible. D'autre part, selon la structure du logiciel et les outils disponibles, il peut être plus ou moins facile de retrouver à quel paquetage appartient une entité. Un des avantages de l'approche objet est justement que tous les aspects liés à une certaine entité appartiennent à un même module; avec une bonne conception et des identificateurs bien choisis, le simple nom de l'entité doit permettre de retrouver où elle a été déclarée. Il existe également des environnements évolués où le simple fait de «cliquer» sur le nom d'une entité ouvre une fenêtre sur le paquetage où elle a été déclarée. Si l'on ne dispose pas de tels outils, ou si la structure du logiciel rend plus difficile l'identification des entités, l'utilisation de noms complets peut faciliter l'identification de l'origine. N'oublions pas non plus le cas des logiciels devant faire l'objet d'une certification, comme ceux de type «aviation». Le certificateur[1] veut être absolument certain de connaître avec précision les éléments appelés. La notation nommée lui garantit l'absence de toute ambiguïté due, par exemple, à des surcharges. Enfin un bon principe de programmation est qu'une opération s'appliquant à un objet précis doit mentionner le nom de cet objet. Si l'on appelle un sous-programme qui est une opération d'un type de donnée abstrait, pas de problème: l'objet auquel il s'applique figure dans les paramètres. Mais s'il s'agit d'une opération d'une machine abstraite, l'objet auquel elle s'applique est représenté en fait par le paquetage dans lequel il est déclaré: il est tout à fait logique dans ce cas de préfixer l'opération par le nom du paquetage.
      1. À qui incombe la lourde responsabilité d'autoriser le logiciel à contrôler un avion avec 300 personnes à bord...
    5. Quelques bonnes raisons d'utiliser les noms simples
    6. Un nom simple est naturellement plus lisible qu'un nom complet; comparez par exemple les deux formulations suivantes:
      Float_Text_IO.Put (Fonctions_Mathématiques.Sin (X));
      Put (Sin (X));
      

      La longueur n'est pas seule en cause: on a remarqué depuis longtemps que dans le processus de lecture, l'attention se focalise d'abord sur le début du mot. C'est pourquoi dans la plupart des langues, les marques (accessoires) de déclinaison, conjugaison, etc. sont situées à la fin des mots. Malheureusement, la notation pointée fait exactement le contraire: la partie la plus intéressante de la notation (la fonctionnalité appelée) se trouve à la fin. Il paraît donc logique de nommer les entités par leur nom simple. D'autre part, les noms simples, avec le mécanisme de surcharge, renforcent le principe d'abstraction en focalisant l'attention sur la fonctionnalité logique et non sur les différences de réalisation. Si par exemple nous écrivons:

      Integer_Text_IO.Get (I);
      Float_Text_IO.Get (F);
      

      nous attirons l'attention du lecteur sur la différence entre ces deux procédures, alors qu'elles réalisent la même chose: l'impression d'un nombre. Si nous avions vraiment voulu montrer cette différence, il suffisait de ne pas utiliser la surcharge et d'appeler les sous-programmes Integer_Get et Float_Get. La situation est encore pire pour les instanciations de certains génériques:

      type Longueur is new Float;
      package Fonctions_sur_Longueur is new
      	Ada.Numerics.Generic_Elementary_Functions (Longueur);
      type Temps is new Float;
      package Fonctions_sur_Temps is new
      	Ada.Numerics.Generic_Elementary_Functions (Temps);
      

      Cela n'aurait aucun sens de rappeler explicitement au lecteur que la fonction Sin portant sur Longueur se trouve dans un paquetage différent de la fonction Sin portant sur Temps.

      Le paquetage Ada.Numerics.Generic_Elementary_Functions fournit les fonctions élémentaires (Sin, Log, etc.) sur n'importe quel type flottant et doit être instancié sur chaque type particulier, selon le même mécanisme que les entrées-sorties. Ce paquetage, qui était défini par une norme séparée en Ada 83, a été intégré dans la norme Ada 95 et est donc fourni par toutes les implémentations.

      Enfin, la présence de bibliothèques hiérarchiques rend l'utilisation de la clause use encore plus nécessaire: les noms des préfixes en son absence tendent à dépasser nettement ce qui est acceptable.

    7. Critères de choix
    8. Sans vouloir relancer tout le débat, rappelons ici quelques éléments à prendre en compte pour décider d'une politique d'utilisation de noms simples ou de noms complets.
    • Si la découpe est fonctionnelle et conduit à des difficultés de localisation des éléments utilisés, on peut imposer des noms complets, sauf si l'on dispose d'outils d'environnement permettant de localiser aisément les identificateurs.
    • Si l'on a de fortes contraintes de certification, on peut exiger que tout nom soit préfixé par le nom du paquetage dans lequel il apparaît. On peut quand même utiliser la clause use pour ne mentionner que le nom du dernier enfant dans le cas d'unités hiérarchiques. Si on n'a pas ou peu fait usage d'unités hiérarchiques, il est possible de faire vérifier le bon respect de la règle par le compilateur en interdisant la clause use.
    • Les opérations définies dans des paquetages de type «machine abstraite» seront préfixées par le nom du paquetage.
    • Dans les autres cas, on préférera l'utilisation de noms simples. On limitera cependant la portée des clauses use à la plus petite zone de visibilité où l'on utilise effectivement le paquetage.
    Il est possible d'imposer localement des contraintes supplémentaires. Ainsi, dans l'exemple de classification de la deuxième partie, nous avions les paquetages Employé, Salarié, Commissionné, etc., qui déclaraient chacun un type Instance et un type Classe. L'intention était que le programmeur les utilise toujours sous la forme Employé.Classe ou Salarié.Instance. Ceci est vérifié par le compilateur: même en présence de clauses use, ces types (volontairement) identiques vont se cacher les uns les autres et le programmeur sera bien obligé d'utiliser les noms complets[1].
    1. Sauf dans le cas particulier où un module n'utiliserait qu'une seule classe... mais l'intérêt de faire de la classification avec une seule classe est plutôt douteux ! Et de toute façon, il n'y aurait plus aucune ambiguïté possible.

    Choix des types modifier

    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 modifier

    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.

    Ada dispose d'attributs permettant de transformer facilement des énumératifs en chaînes de caractères, ainsi que de sous-programmes d'entrées-sorties qui leur sont applicables. Ceci les rend beaucoup plus utilisables qu'en Pascal.

    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.

    Attention: le type Integer peut parfois réapparaître de façon insidieuse. Si par exemple l'on écrit:
        for I in 1..10 loop ...
    
    le compilateur ne dispose d'aucune information sur le type de I et choisit donc Integer, faute de mieux. Il est toujours possible (et préférable) de donner explicitement le type:
        for I in Age range 1..10 loop ...
    
    Ceci s'applique également aux indices des types tableau. Pour une fois qu'Ada avait tenté d'économiser quelques touches à taper au programmeur, l'inconvénient se révèle supérieur au gain...

    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:

    • Le modèle impose des contraintes absolues. Celles-ci sont directement reproduites dans la déclaration:
      type Jour_de_l_Année is range 1..366;
      
    • Le modèle n'impose que des contraintes minimales; le programmeur souhaite une implémentation matérielle efficace couvrant au moins les limites données:
    type Longueur_minimale is range 0..200;
    subtype Longueur_Utile is Longueur_minimale'Base;
    
    Le type de base (obtenu par l'attribut 'Base) est le type machine sous-jacent choisi par le compilateur. Il est donc plus vaste que le type d'origine. Cette utilisation de l'attribut 'Base est nouvelle en Ada 95.
    • Le programmeur souhaite imposer des limites liées au matériel, par exemple pour des interfaçages. On définira le type en conséquence et on l'assortira d'une clause de représentation:
    type INT_16 is range -2**15..2**15-1;
    for INT_16'Size use 16; -- Représenter le type sur 16 bits
    
    En Ada 95, le paquetage Interfaces contient en standard la définition de tous les types machines supportés par le compilateur, sous la forme Integer_8, Unsigned_16, etc. Le programmeur n'a donc plus à les récrire.
    • Le programmeur a besoin d'un type entier, non lié à une sémantique particulière, sans contrainte d'efficacité... En fait, si c'était possible, il lui faudrait l'ensemble (infini) des entiers relatifs, au sens mathématique. Il se contentera du plus grand type possible:
      type Grand_Entier is range System.Min_Int..System.Max_Int;
      
    Le paquetage System est présent sur toutes les implémentations et contient des déclarations de constantes permettant de connaître les caractéristiques de la machine sur laquelle on s'exécute. Par exemple, Min_Int et Max_Int sont la plus petite et la plus grande valeur entière supportées par l'implémentation.

    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:

    • Pour indexer le type String. Ce type a, par définition, Integer (ou plutôt son sous-type Positive) comme type d'index. Toutes les variables servant à manipuler les chaînes seront donc de type Integer.
    • Lorsque le besoin est celui d'un type de taille raisonnable, adapté aux possibilités de la machine; en particulier comme indice de tableau ou comme limite lorsque l'on n'a rien de mieux sous la main. Integer est censé être le type entier «naturel» de la machine et il doit permettre une indexation efficace. On préférera l'utiliser sous forme de type dérivé, pour éviter les mélanges:
    type Index is new Integer;
    type Liste is array (Index range <>) of Angle;
    

    Types réels modifier

    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:

    • Décider d'abord si les grandeurs se représentent mieux sur une échelle linéaire (types point fixe) ou logarithmique (types point flottant). Noter que pour un même nombre de bits, la précision est meilleure avec les points flottants au voisinage de 0, mais qu'au contraire elle est meilleure pour les grandes valeurs avec les points fixes . En particulier, toute représentation du temps (pour lequel la notion de zéro est arbitraire) se définit normalement avec des points fixes[1].
    • Dans le cas d'une échelle linéaire, décider si l'on a un modèle de calculs approchés, ou si le besoin est celui d'une arithmétique exacte (comme avec des entiers), mais avec un pas différent de 1. Dans le premier cas, on utilisera des points fixes normaux:
      type Volts is delta 0.01 range 0.0 .. 380.0;
      
      Dans le second, si le pas souhaité est une puissance de 10, on utilisera des décimaux:
      type Francs is delta 0.01 digits 10;
      
      Autrement, on mettra une clause de représentation pour garantir le bon pas:
    type Position is delta 1.0/50.0 range 0.0 .. 1.0;
    for Position'Small use 1.0/50.0;
    
    • Dans le cas d'une échelle logarithmique, on peut souhaiter une précision qui soit fonction des capacités de la machine ou au contraire une précision indépendante de la machine. Par exemple, si l'on fait un programme graphique, la précision des calculs n'a pas grande importance et l'on souhaite utiliser les flottants normaux de la machine. Bien sûr, on préservera l'abstraction en n'utilisant pas directement le type Float, mais un type dérivé:
    type Coordonnée is new Float;
    type Coordonnée_précise is new Long_Float;
    
    Noter qu'il est alors possible de connaître (de façon portable) la précision a posteriori des calculs grâce aux attributs tels que Coordonnée'Digits.

    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;
    
    Noter que selon la représentation interne de la machine, cette précision peut tenir ou non sur 32 bits; le compilateur choisira donc une représentation interne en simple ou double précision, selon la machine. On aura peut-être plus de précision (ce qui ne sert à rien puisqu'on pilote des vérins dont la précision est limitée), mais la précision minimale est garantie.

    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.

    1. C'est pour n'avoir pas suivi ce conseil que le programme (écrit en C !) qui contrôlait le départ des missiles Patriot pendant la guerre du Golfe a raté un Skud qui causa la mort de plus d'une dizaine de personnes.

    Chaînes de caractères modifier

    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.

    • Les messages. Il s'agit de chaînes destinées à dialoguer avec l'utilisateur et qui ne sont donc pas des représentations d'entités de plus haut niveau. Le type prédéfini String est parfaitement approprié. Le paquetage Ada.Strings.Fixed fournit toutes les fonctionnalités habituelles: déplacement, recopie, transcodage, recherche, etc.
    • Des types de données abstraits, que l'on choisit de représenter au moyen de chaînes de caractères. Si les propriétés du type abstrait conduisent à la conclusion que toutes les chaînes ont la même longueur, on utilise simplement un sous-type de String. Si au contraire on a besoin de chaînes de longueur variable, on instancie le paquetage Generic_Bounded_Length (qui est fourni dans le paquetage Ada.Strings.Bounded). Ce paquetage fournit un type de chaîne de caractère de longueur variable, dont la taille maximale est donnée à l'instanciation. Bien entendu, la taille maximale choisie doit résulter d'une analyse des propriétés du type de donnée abstrait. Par exemple, on peut choisir de représenter le nom d'une personne par une chaîne variable et décider que la longueur maximale sera de 15 caractères:
    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.

    • Le stockage de caractères. Le besoin n'est pas à proprement parler celui d'une «chaîne», mais plutôt d'un tableau de caractères que le programme manipule. En général, la taille de ce tableau est virtuellement infinie; un exemple typique est celui d'un programme de traitement de texte qui stocke tout le document dans un seul grand tableau en mémoire. On utilise alors le type Unbounded_String (ou plutôt un type dérivé), du paquetage Ada.Strings.Unbounded:
      type Document is new Unbounded_String;
      
    Attention: la limite des Unbounded_String (il en faut bien une) est donnée par Natural'Last, c'est-à-dire 2_147_483_647 pour une implémentation de Integer sur 32 bits (ce qui devrait suffire amplement), mais seulement 32_767 pour une implémentation 16 bits, ce qui peut s'avérer insuffisant. On risque donc d'avoir une non-portabilité à ce niveau.

    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 modifier

    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 modifier

    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:

    La probabilité de «bugs» dans un logiciel est directement proportionnelle à la densité des pointeurs.

    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:

    • Il est possible d'avoir des structures de données, notamment des tableaux, dont la taille n'est connue qu'à l'exécution sans utiliser pour autant l'allocation dynamique.
    • Le mécanisme de la programmation orientée objet n'est pas lié à la notion de pointeur.
    • Les génériques offrent une alternative à l'utilisation de pointeurs pour fournir à un sous-programme ou à un paquetage des références à des éléments qui leur sont extérieurs (sous-programmes, variables) .

    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 modifier

    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[1]. 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.

    1. Les numériciens ont tellement besoin de ces fonctionnalités de manière portable (ce qui n'est pas le cas de notre solution) qu'Ada 95 offre de nouveaux attributs fournissant directement ces services.

    Conclusion sur l'utilisation des types modifier

    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 modifier

    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 modifier

    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[1]. Voici quelques règles empiriques pour guider ce choix:

    • Un module dont on n'a aucune raison de penser qu'il puisse se généraliser n'est pas mis sous forme de générique.
    • Un module qui a toutes les chances d'être généralisé est écrit directement sous forme de générique.
    • Un module dont on ne pense pas qu'il puisse être généralisé, au moins à court terme, est écrit sous forme non générique, mais on prendra des précautions pour faciliter un éventuel passage ultérieur en générique.
    • Il vaut mieux adapter un module déjà développé en le passant en générique que de récrire entièrement une entité voisine, surtout si l'on est tenté de faire des duplications à partir de l'existant.
    1. Sauf dans le cas d'une première version développée sous forme non générique pour faciliter la mise au point, mais dont la structure a été pensée dès le départ pour devenir générique par la suite.

    Point de vue méthodologique modifier

    1. Héritage et généricité
    2. À faire... 

      Parler des interfaces

      On a souvent besoin d'écrire des algorithmes applicables à plusieurs types de données, ce qui peut être obtenu par des génériques ou par des techniques d'héritage. Tous les ouvrages portant sur les langages à objets (notamment [Mey90]) comportent un chapitre comparant les avantages et inconvénients respectifs de ces deux solutions. Ils sous-estiment généralement les possibilités de la généricité[1] et surtout ne la considèrent que comme un succédané d'héritage dans les langages d'où celui-ci est absent. En fait, généricité et héritage relèvent chacun de sa propre démarche et il n'y a aucune raison de considérer l'un comme «supérieur» à l'autre.

      Reprenons l'exemple qui nous a permis d'introduire la notion de liaison dynamique dans la deuxième partie: le déplacement d'un objet graphique. En termes de généricité, ce problème s'analyserait de la façon suivante:

      Pour tout objet muni de procédures d'effacement, de dessin et de la notion de point caractéristique dont on peut mettre à jour la position, on peut écrire l'algorithme de déplacement.

      Cette analyse conduit à la définition de la procédure générique suivante:

      generic
      	type Objet       is limited private;
      	type Coordonnées is private;
      	with procedure Dessiner (Quoi : Objet);
      	with procedure Effacer  (Quoi : Objet);
      	with procedure Mise_A_Jour (Quoi : Objet;
      	                            X, Y : Coordonnées);
      procedure Déplacer (Quoi : Objet; X, Y : Coordonnées);
      procedure Déplacer (Quoi : Objet; X, Y : Coordonnées) is
      begin
      	Effacer (Quoi);
      	Mise_A_Jour (Quoi, X, Y);
      	Dessiner (Quoi);
      end Déplacer;
      

      Si la formulation algorithmique est sensiblement équivalente à celle obtenue dans le cas de l'héritage, sa philosophie est différente. Il s'agit ici d'une abstraction algorithmique extérieure aux objets manipulés: Déplacer est applicable à tout type ayant les propriétés minimales requises, sans pour autant que ceux-ci aient quoi que ce soit en commun. Par exemple, on peut imaginer une grue (jouet) télécommandée, capable de poser et de ramasser des cubes. On peut raisonnablement assimiler le fait de poser un cube à Dessiner (le cube «apparaît» sur le sol), ainsi que le fait de prendre un cube à Effacer, Mise_A_Jour déplaçant physiquement la grue sur le sol. Dans ce contexte, notre algorithme peut être utilisé pour déplacer un cube d'un point à un autre. Une démarche par héritage demanderait que l'objet Grue hérite de la classe des objets graphiques, ce qui signifierait que nous considérons qu'une grue est une forme d'objet graphique. Bien sûr cela fonctionnerait du point de vue informatique, mais ce serait tout de même conceptuellement gênant...

      On ne peut réutiliser un algorithme que s'il existe une certaine parenté entre les utilisateurs; mais le changement de technique conduit à une inversion de la hiérarchie des valeurs. Avec la généricité, on définit un type de donnée abstrait dans un paquetage et on lui rajoute des fonctionnalités par instanciation de générique: c'est donc le code réutilisé qui vient enrichir l'abstraction de l'utilisateur. En revanche, pour récupérer un algorithme appartenant à une classe par héritage, il faut en hériter, c'est-à-dire incorporer son propre type de données à la famille de la classe dont on hérite. Pour prendre une image, si on instancie un générique pour rajouter des fonctionnalités à un type, on soumet l'algorithme instancié aux besoins du type à concevoir; si en revanche, on fait hériter le type d'une classe, on soumet le type à concevoir à la classe dont on hérite.

      Si dans les deux cas il y a dépendance du réutilisateur vers le réutilisé, la dépendance est moins forte avec la généricité, car il n'y a pas de relations entre deux types qui instancient un même générique, alors qu'il y en a nécessairement une, au moins conceptuelle, entre deux types qui héritent d'une même classe. Nous considérerons donc que la généricité induit un couplage moins fort que l'héritage.

      En résumé, on peut dire que la solution par héritage permet une meilleure concentration des propriétés, mais que la généricité fournit une meilleure indépendance. On utilisera la généricité pour exprimer un algorithme applicable à différents types de données vérifiant un certain nombre de propriétés minimales, sans qu'il y ait nécessairement de lien logique entre ces types; inversement, on utilisera l'héritage si l'algorithme n'est applicable qu'à une famille de types liés par une dépendance conceptuelle.

      1. En ne considérant que les possibilités de paramétrisation par les types et en omettant la possibilité d'importer les opérations associées, ce qui réduit considérablement la puissance des génériques.
    3. Généricité et pointeurs sur sous-programmes
    4. Un autre cas où l'on doit se poser la question de l'utilisation ou non de génériques est celui d'algorithmes paramétrables par un sous-programme. Si nous voulons calculer l'intégrale d'une fonction, nous pouvons écrire:
      generic
      	with function Fonction_Math (X: Float) return Float;
      procedure Intégrer (Début, Fin : Float);
      function Sin (X: Float) return Float;
      procedure Intégrer_Sinus is new Intégrer (Sin);
      ...
      Intégrer_Sinus (0.0, 1.0);
      

      Nous pouvons aussi passer un pointeur sur fonction:

      type Fonction_Math is access function (X: Float) return Float;
      procedure Intégrer (Ptr_Fonction : Fonction_Math;
                          Début, Fin   : Float);
      function Sin (X: Float) return Float;
      ...
      Intégrer (Sin'Access, 0.0, 1.0);
      

      La seconde solution est la seule permise par d'autres langages ne disposant pas de la généricité; elle paraît donc plus naturelle aux nouveaux venus à Ada. Inversement, la première solution était la seule permise par Ada 83 et sera préférée par les vétérans du langage. La principale différence (en dehors de quelques considérations mineures d'efficacité) provient des règles de portée. Comme toute déclaration, une instanciation de générique est utilisable depuis sa déclaration jusqu'à la fin de sa portée: il n'y a aucune mauvaise surprise à craindre, on peut instancier le générique avec n'importe quelle fonction et toute mauvaise utilisation sera détectée à la compilation. En revanche, la seconde solution n'autorise de passer à la procédure que des fonctions dont la durée de vie soit supérieure à celle de la procédure elle-même, afin d'interdire toute référence à une fonction qui n'existerait plus. Dans certains cas, ce test doit être effectué à l'exécution (avec levée éventuelle de l'exception Program_Error). Ceci limite quasiment les fonctions que l'on peut désigner par un pointeur à celles déclarées au niveau le plus externe[1]; on préférera donc souvent la solution générique. Il est cependant un cas qui ne peut être traité que par pointeur sur sous-programme: celui où l'on souhaite stocker l'identité du sous-programme pour l'appeler par la suite, cas fréquent lorsque l'on veut «enregistrer» un traitement pour l'appeler par la suite lors de la survenue d'un événement (mécanisme dit du call back, fréquent dans les systèmes de fenêtrage); dans ce cas l'utilisation du pointeur sur sous-programme est impérative.

      1. Les autres langages ne font pas mieux ; simplement, comme les fonctions C sont toujours définies au niveau le plus externe (il n'y a pas d'imbrication), il n'est pas besoin de préciser cette règle...

    Point de vue des performances modifier

    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[1]. 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:

    On ne doit mettre en générique que les parties qui dépendent spécifiquement des paramètres génériques.

    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[2] 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.

    1. Se méfier en particulier de la notion de «macros» présente dans d'autres langages, qui conduit à des duplications de code que l'on a tendance à oublier.
    2. Ce conditionnel est là pour rappeler au lecteur que toute considération d'efficacité doit faire l'objet d'une mesure.

    Choix liés au parallélisme modifier

    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 modifier

    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.

    L'utilisateur peut spécifier au moyen du pragma Storage_Size l'espace mémoire d'une tâche. Ce pragma, comme l'attribut d'Ada 83 qu'il remplace, peut être dynamique: on peut donc ajuster à l'exécution l'espace des tâches.

    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 modifier

    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
    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? modifier

    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 modifier

    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]:

    • Y a-t-il des cas exceptionnels? Comme nous l'avons vu à propos des composants logiciels, il est souvent possible (mais pas toujours souhaitable) de compléter la sémantique au lieu de lever une exception. Par exemple, si nous avons une fonction de recherche d'une sous-chaîne dans une chaîne, il est souvent plus simple, même pour l'utilisateur, de renvoyer une valeur conventionnelle[1] lorsque la chaîne recherchée n'est pas présente, plutôt que de lever une exception.
    • En cas de condition exceptionnelle, faut-il lever une exception, ou utiliser le mécanisme défini par le projet pour gérer les erreurs? Tout se joue ici sur la distinction entre «erreur» et «exception», ainsi qu'entre composant réutilisable et module spécifique du projet.
    • Faut-il fournir une fonction d'interrogation pour savoir si l'exécution du sous-programme est possible? Il faut pour cela qu'une telle fonction ait une utilité et que sa complexité soit faible devant celle de la fonctionnalité testée. Il est par exemple utile de fournir une fonction testant la fin de fichier avant lecture; mais une fonction qui fournirait un prédicat pour savoir si une matrice est inversible serait peu utile et presque aussi complexe que l'inversion de matrice elle-même.
    • Faut-il fournir une fonction d'interrogation permettant, en cas d'anomalie, d'obtenir plus de détails sur la cause? Une telle fonction peut être très utile, mais ne s'applique qu'aux anomalies «sophistiquées»; de plus, il faut prendre des précautions pour assurer la réentrance en cas d'utilisation du parallélisme.
    Ada 95 offre des outils permettant d'avoir plus d'information sur la cause d'une exception, comme d'attacher un message spécifique à la levée d'une exception. De plus, les attributs de tâches permettent de stocker des valeurs dans le contexte de chaque tâche et donc d'écrire des fonctions d'interrogations réentrantes.
    1. Noter que l'habitude est de renvoyer 0, mais il serait souvent plus commode pour les algorithmes de renvoyer une valeur supérieure à l'index du dernier élément.

    Conclusion modifier

    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

    Exercices modifier

    1. Écrire un algorithme d'intégration d'une fonction entre deux bornes. En faire ensuite un générique auquel on passe la fonction en formel, puis une procédure à laquelle on passe un pointeur sur la fonction. Comparer les performances.
    2. Étudier la structure d'un mécanisme de fenêtrage qui serait fondé sur le parallélisme et non sur le mécanisme des callbacks.