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

Nous avons vu dans la quatrième partie qu'il était possible de définir une méthode «maison» adaptée au développement d'un projet particulier.

Une méthode orientée objet pour les projets de taille moyenne

C'est ce processus que nous allons tenter d'illustrer dans cette partie. Le but n'est donc pas tant de développer «une méthode de plus» que de montrer comment les idées que nous avons développées peuvent être mises en œuvre à l'intérieur d'un cadre méthodologique fondé sur les méthodes existantes.

Cahier des charges de la méthode modifier

Énoncé modifier

Comme indiqué dans le titre de cette partie, nous voulons développer une méthode pour des projets de taille moyenne (soit, en première approximation, de 10000 à 100000 lignes de code). Nous visons donc une classe de problèmes qui requiert une véritable équipe de développement, mais de taille telle qu'il soit encore possible d'organiser des réunions rassemblant la totalité de l'équipe. Il s'agit d'une méthode de conception détaillée: on suppose que l'on dispose d'un cahier des charges (qui n'est pas forcément ni aussi complet, ni aussi précis que l'on pourrait le souhaiter) et que le but est d'obtenir un programme réalisant ce cahier des charges. En particulier, le problème des spécifications matérielles est supposé résolu, ou tout au moins suffisamment avancé pour permettre aux développeurs logiciels de ne pas s'en soucier.

La méthode doit servir pour des applications à longue durée de vie: la documentation, la compréhensibilité de la structure du projet par des personnes n'ayant pas participé au développement initial, sont donc primordiales. En revanche, on souhaite limiter les phénomènes d'avalanche documentaire observés avec des méthodes destinées aux problèmes plus importants: la documentation doit rester concise et abordable.

La méthode ne doit pas imposer de trop fortes contraintes: il faut qu'elle aide le développeur sans lui imposer un cadre trop rigide qui risquerait, en l'absence de directives «militaires», de l'en détourner. À priori, la méthode s'appliquera à tous les domaines de l'informatique. En particulier, elle doit pouvoir être mise en œuvre pour des applications de type «temps réel», hors peut-être celui du temps réel à très fortes contraintes de performances et/ou de sécurité pour lequel des méthodes spécifiques sont nécessaires.

Cette méthode doit permettre d'utiliser au mieux le langage Ada. En particulier, elle doit prendre en compte les nouvelles possibilités apportées par Ada 95. Le processus de développement utilisera le maquettage progressif; compte tenu de la taille visée, il est encore possible de gérer l'avancement du projet en travaillant ainsi; en revanche, la facilité d'intégration qu'il apporte est un grand facteur d'économie d'effort de développement. Enfin, nous souhaitons promouvoir la réutilisation et le développement de composants logiciels, ainsi que l'évolutivité, tout en maintenant la sécurité. Nous nous plaçons donc à un niveau intermédiaire entre la méthode de Booch, qui est trop imprécise pour être utilisée directement et la méthode HOOD qui vise des projets de taille supérieure et où la réutilisation n'est pas une critère fondamental.

Pourquoi avoir choisi ce niveau pour illustrer notre propos? Parce qu'il s'agit d'une taille de problèmes qui requiert l'utilisation d'une méthode, mais où les programmeurs peuvent encore être tentés de n'en utiliser que de façon informelle. Nous pensons donc que le «marché» de la méthode est important.

Quelques idées directrices modifier

Choix de la méthode de départ modifier

Nous l'avons déjà dit, mais on ne le répétera jamais assez: la complexité est l'ennemi n°1 de l'informaticien. Il faut arriver à un morcellement du travail tel que chaque élément pris individuellement reste toujours simple: soit qu'il s'agisse d'un petit morceau de projet aux spécifications parfaitement déterminées (on ne se préoccupe pas alors du contexte dans lequel il intervient), soit au contraire qu'il s'agisse d'un élément de plus haut niveau qui se contente d'utiliser des services de niveaux inférieurs, oubliant alors délibérément comment ceux-ci fonctionnent en interne. Le cloisonnement strict des rôles jouera donc un rôle fondamental dans la méthode. Or nous avons vu que ce cloisonnement ne peut être obtenu avec les méthodes orientées objet par classification: notre base de départ sera donc une méthode orientée objet par composition. Ceci n'exclut pas d'utiliser l'héritage, mais seulement dans les couches profondes de la conception. En fait, un des buts de la méthode est de permettre d'utiliser l'héritage fourni par Ada 95 lorsqu'il est utile, en évitant la complexité inhérente aux méthodes par classification. Nous définirons donc cette méthode comme une méthode orientée objet avec priorité à la composition. Et comme il est de bon ton de donner un nom aux méthodes, nous l'appellerons C-FOOD[1] pour Composition-First Object Oriented Design.

  1. Jeu de mots intraduisible : C-FOOD se prononce en anglais comme « sea food » (fruits de mer), particulièrement appréciés par l'auteur !

Hiérarchisation modifier

La méthode doit permettre de définir des couches successives, clairement identifiées et avec une profondeur supérieure à 2! Trop souvent les méthodes passent directement du premier niveau d'analyse à l'implémentation. Nous chercherons au contraire à définir un processus qui favorise l'organisation du logiciel en niveaux suffisamment nombreux pour que la complexité de chacun reste dans les limites de ce qui est facilement compréhensible.

Documentation et méthodologie modifier

Nous avons précédemment expliqué que les documents qui servent au processus de conception ne sont pas les plus appropriés à la maintenance. Nous introduirons donc dans la méthode une phase de documentation de la solution obtenue, qui ne proviendra pas des documents de conception.

On cherchera à minimiser cette documentation: elle ne devra comprendre que ce qui est nécessaire à la compréhension de la structure du programme. On rendra au contraire le code aussi auto-documenté que possible, en ne laissant à la documentation externe que ce qui ne peut s'exprimer directement au niveau du langage[1]. Nous utiliserons même Ada (avec, en particulier, l'aide du paquetage ADPT), comme moyen d'expression de nos conceptions.

  1. Ceci n'est bien entendu rendu possible que par le très grand pouvoir d'expression du langage Ada ; ce n'est pas forcément applicable à des langages de plus bas niveau.

Phases modifier

Toute méthode est un processus itératif; elle permet d'identifier des sous-problèmes, que l'on résout en leur appliquant à nouveau la méthode (cf. paragraphe Surmonter la complexité). Cependant, il existe des éléments à analyser au début du projet, avant de pouvoir lancer l'analyse itérative; et il est bon de tirer un bilan après achèvement de la conception. Ces étapes doivent être formalisées au niveau de la méthode, mais sont distinctes du processus général de conception itérative. Aussi définirons-nous trois phases dans notre méthode: la phase d'initialisation où nous mettons en place la structure du projet, la phase de conception itérative proprement dite et une phase de conclusion une fois celle-ci terminée.

Description de la méthode modifier

Principes de la méthode modifier

La méthode que nous proposons ne fait plus de différence entre conception préliminaire, conception détaillée, analyse et codage. Elle définit une suite progressive de descriptions du problème, par niveau sémantique décroissant. Chaque étape est validée par des techniques de maquettage avant de passer à l'étape suivante. La méthode est itérative et s'arrête lorsque la description obtenue est complète, c'est-à-dire ne comporte plus de partie maquettée.

Elle est essentiellement fondée sur la méthode de Booch, le critère de décomposition horizontale étant l'objet en tant qu'abstraction d'un objet du monde réel et le critère vertical le niveau d'abstraction. Les changements que nous proposons visent à systématiser dans ce cadre le maquettage progressif et l'utilisation (ou la définition) de composants logiciels réutilisables, à préciser le processus de conception lui-même et à définir une politique de documentation. Nous avons de plus incorporé certaines idées provenant d'autres méthodes apparentées, de HOOD notamment.

Première phase: initialisation modifier

Comprendre le problème modifier

Avant même de songer à une éventualité de solution, il est indispensable de s'assurer que l'on a bien compris le problème[1]. On dispose généralement d'une description, que nous appellerons pour simplifier le cahier des charges. Rien ne prouve a priori que ceux qui l'ont rédigé[2] maîtrisaient eux-mêmes la complexité de ce qu'ils ont demandé. Il faut donc commencer par faire une revue de détail du cahier des charges, s'assurer que la description du travail à effectuer est claire et faire la chasse aux imprécisions, manques, non-dits et contradictions. On supposera au départ que ces imperfections existent; si l'on n'en trouve pas, c'est que notre compréhension du problème est insuffisante.

Toute zone d'ombre dans le cahier des charges doit être discutée en équipe par les développeurs et faire l'objet d'une demande de clarification auprès du client s'il apparaît qu'elle résulte effectivement d'un défaut du cahier des charges. Rien n'est plus dangereux en effet que de partir sur une interprétation «évidente», qui se révélera (mais trop tard) ne pas correspondre à ce qui était effectivement demandé. En particulier, penser que le client a rédigé le cahier des charges en utilisant son propre «fonds culturel». Des notions, des termes qui lui paraissent évidents peuvent être interprétés de façon différente par l'équipe de développement. On veillera donc en particulier à ce que tous les termes employés, surtout ceux qui bénéficient d'un sens communément accepté, soient définis de façon précise dans le cahier des charges.

  1. Ceci correspond à l'étape H1.1 de la méthode HOOD.
  2. Que nous appellerons pour simplifier le «client». Il peut s'agir effectivement d'un client extérieur, aussi bien que d'une autre équipe de la même entreprise dans le cas de développement de gros projets.

Faire les choix fondamentaux modifier

  1. Choix organisationnels
  2. L'équipe de programmation peut être ou non définie au départ du projet. Si elle ne l'est pas, c'est le premier travail du chef de projet que de la constituer. Il convient d'affecter les rôles que nous avons présentés, de s'assurer que chacun a bien compris ses propres responsabilités et celles de ses collègues.
  3. Choix techniques
  4. Il convient ensuite de faire les choix techniques qui s'imposeront à l'ensemble du projet. Un certain nombre d'entre eux sont typiquement du ressort des programmeurs, mais certains doivent être décidés dès le départ du projet. Nous rappelons ici ceux qui sont les plus critiques pour la bonne marche future du développement.
  • Choix de la méthode de conception
Il convient d'être parfaitement clair dès le départ quant à la méthode choisie. Nous avons vu les différents éléments à prendre en compte pour choisir la méthodologie la plus appropriée; mais quitte à paraître un peu cynique, nous affirmerons que peu importe la méthode choisie, l'important est d'en avoir une, de la suivre et d'être cohérent avec elle sur toute la durée du projet. La responsabilité de ce choix est du ressort du chef de projet, assisté du responsable qualité, en accord avec les programmeurs... dans la mesure où ils sont déjà connus. Il arrive en effet que l'on choisisse (ou impose) une méthode pour un projet, puis que l'on désigne les développeurs dont les connaissances correspondent au besoin.
  • Organisation de la bibliothèque de programme
Le responsable configuration doit dès le départ étudier les possibilités de l'environnement de développement choisi pour définir les procédures de mise en configuration des modules livrés et les conditions de mise à disposition auprès des autres membres de l'équipe. Il doit aussi décider de l'utilisation de fonctionnalités comme les bibliothèques liées ou les sous-bibliothèques par rapport à la structure de l'équipe.
  • Gestion des anomalies
Nous avons vu dans la quatrième partie diverses possibilités de gestion des anomalies. Il faut décider d'une politique globale pour tout le projet. Même si le choix final se porte sur une solution «triviale» (comme d'utiliser simplement le mécanisme des exceptions), il importe que ceci résulte d'une décision concertée et non simplement de l'absence de décision à ce sujet. Cette décision doit être prise par le chef de projet, assisté du responsable qualité, en contact direct avec les programmeurs.
  • Choix des composants de base
Il est généralement possible dès ce niveau de se douter que le projet devra utiliser certains composants logiciels: structures de données habituelles (piles, files, etc.), bases de données, interface avec un système de fenêtrage... Le chef de projet, en liaison avec le responsable composant logiciel, devra décider du choix (et de l'achat éventuel) de tels composants. Bien sûr, s'il existe déjà une base «maison» de tels composants, le plus simple sera de l'utiliser (à moins qu'elle ne réponde pas aux besoins prévus). Sinon, il faudra étudier le marché pour décider soit d'acheter des composants tout faits (et les choisir, car ce marché est aujourd'hui concurrentiel), soit de les faire développer dans le cadre du projet. Éventuellement, le responsable financier pourra être chargé de négocier avec la hiérarchie une «rallonge» supplémentaire (en autofinancement) pour permettre le développement de ces composants d'une manière suffisamment réutilisable pour qu'il puisse servir dans des projets ultérieurs.
  • Mise en place des documents
  • Toute méthode s'accompagne d'un certain nombre de documents. Il faut dès le départ être clair sur qui écrit quels documents, comment ces documents sont conservés et comment on peut y avoir accès. Selon les cas et les outils utilisés, ces documents seront sur papier, sur support informatique, multimédia... Peu importe, du moment que les choix sont, comme pour le reste, délibérés et explicites. La mise en place des documents est bien entendu de la responsabilité du responsable documentation, en accord avec le responsable communication et sous contrôle du responsable qualité. Certaines méthodes, telles HOOD, sont très explicites quant à la documentation et les outils la gèrent directement; il y a alors peu de décisions à prendre. D'autres sont beaucoup plus vagues, parfois totalement silencieuses, quant à la définition des documents à produire. D'autre part, les habitudes de l'entreprise en matière de documentation sont également à prendre en considération. Nous pensons cependant que quelle que soit l'organisation pratique adoptée, on doit trouver sous une forme ou sous une autre au moins trois documents: l'historique, la documentation d'utilisation et le cahier des décisions de conception. L'historique récapitule par ordre chronologique les grandes étapes du projet. Il est tenu par le responsable communication et contient entre autres les comptes rendus des réunions et les étapes marquantes du développement. Il doit permettre de savoir où l'on se trouve par rapport au plan de développement prévu... ou permettre de retrouver a posteriori l'origine d'un glissement. La documentation d'utilisation est le mode d'emploi de chacun des modules. Elle doit permettre à un utilisateur de se servir du module sans aller regarder dans l'implémentation. Elle peut rappeler la spécification syntaxique (à moins que l'on ne dispose de liens hypertexte...), mais doit surtout s'attacher à décrire ce qui n'apparaît pas d'après la syntaxe: pré- ou post-conditions, cas de levées d'exceptions, sémantique de haut niveau, éventuellement temps et performances d'exécution. Le cahier des décisions de conception doit contenir pour chaque choix qui a été fait les motivations de ce choix, les autres possibilités envisagées et les raisons ayant conduit à préférer la solution adoptée. En particulier, lorsque des possibilités ont été étudiées, puis rejetées, il faut conserver les raisons de ce rejet. Le cahier doit être organisé par module (pour les décisions spécifiques à chacun d'eux) et doit permettre à un mainteneur chargé de reprendre le projet par la suite de trouver la réponse à toute question de la forme: «Mais pourquoi ont-ils fait cela?», afin de lui éviter de répéter les erreurs qui ont pu être commises (puis réparées) durant la phase initiale de conception. Il faut absolument séparer la documentation d'utilisation du cahier des décisions de conception, car leurs rôles sont fondamentalement différents. Dans une voiture, le premier correspondrait au manuel remis à l'acheteur et le second serait le manuel technique pour les garagistes. Le tableau de la figure37 résume ces différences.
    Figure 37: Documentation d'utilisation et documentation de conception
    Documentation d'utilisation Cahier des décisions de conception
    Lié à Spécification Corps
    Destiné à Utilisateur du module Mainteneur du module
    Décrit Comportement abstrait Choix d'implémentations
    Figure 37: Documentation d'utilisation et documentation de conception

    La première partie du cahier des décisions de conception doit récapituler les décisions prises lors de cette phase et leurs motivations.

    L'analyse de premier niveau modifier

    La plupart des méthodes ne font pas un cas particulier de la première étape. En particulier, dans les méthodes orientées objet, il faut considérer le système à concevoir comme un objet (au sens de la méthode), semblable à ceux qui seront produits par le déroulement de l'analyse. Cela conduit généralement à des difficultés; le cahier des charges est rarement exprimé en termes «orienté objet», mais plus souvent en termes fonctionnels. L'expression des exigences sous forme d'objets est alors rarement naturelle, conduisant à baptiser «objets» des éléments qui ne sont pas vraiment des abstractions.

    Nous préférons reconnaître qu'en général, le premier niveau d'analyse s'exprime mieux sous forme fonctionnelle. Puisque le cahier des charges exprime un comportement, ce premier niveau d'analyse, qui se concrétisera par le programme principal, peut parfaitement décrire ce comportement. Pour sa réalisation, on utilisera les opérations d'objets qui sont les composants de premier niveau du système. On peut donc voir le programme principal comme une sorte d'automate, dont le fonctionnement met en œuvre, anime, des objets. Cette étape diffère donc de la conception itérative essentiellement en ce que le point de départ ne peut être considéré comme un objet. Elle conduit cependant à l'identification, puis à la définition, d'objets de la même façon que celle décrite dans la phase de conception itérative.

    Deuxième phase: la conception itérative modifier

    Nous allons maintenant aborder la phase principale du développement. Elle est itérative et, comme pour une boucle de programme, il faut définir les invariants de boucle et les conditions de terminaison.

    Par «invariant de boucle», nous entendons qu'à chaque étape nous devons partir d'une description bien définie des objets à concevoir pour aboutir à la définition d'objets de plus bas niveau nécessaires à leur réalisation, décrits de la même manière. Une étape de la conception itérative correspond donc à un plan d'abstraction: à partir de spécifications existantes, réalisation du corps de l'objet à concevoir et définition des spécifications des objets nécessaires, que nous appellerons pour les différencier les objets utilisés. Le plan d'abstraction est ensuite vérifié en le compilant et éventuellement en fournissant des corps-maquettes permettant d'exécuter l'ensemble. Les objets utilisés deviennent alors les objets à concevoir de l'itération suivante.

    Le processus itératif s'arrête soit quand les objets utilisés peuvent être réalisés directement sans faire appel à des objets de plus bas niveau, soit quand tous les objets utilisés existent déjà, dans le projet ou dans des bibliothèques d'objets réutilisables.

    Cette description formelle du processus ne doit surtout pas faire croire qu'une fois la spécification d'un objet établi, elle est immuable; nous verrons même que notre méthode prévoit explicitement une phase de remise en cause des éléments de l'étape précédente. Il arrivera donc souvent qu'une étape ultérieure conduise à un changement d'interfaces d'objets déjà établis et provoque donc une modification d'implémentation d'un plan d'abstraction supérieur. C'est normal; mais quand cela se produit, il faut revenir en arrière, revérifier toutes les conséquences de la nouvelle spécification (éventuellement en remettant à jour les maquettes associées) avant de donner son aval à la nouvelle spécification. Le processus de conception doit donc être vu comme globalement descendant, mais peut présenter des oscillations locales, comme illustré dans la figure 38. Autrement dit, ce qui est important, c'est qu'à chaque remise en cause on remonte un peu moins haut et que l'on descende un peu plus bas.

     
    Figure 38: Modèles de conception descendante
    Figure 38: Modèles de conception descendante

    Notre hypothèse de départ en début d'itération est que nous disposons de définitions des objets à concevoir, syntaxiquement sous forme de spécifications Ada et sémantiquement sous la forme d'une documentation décrivant leurs propriétés. Ces spécifications sont supposées suffisantes pour réaliser le plan d'abstraction qui les a définies. Nous allons maintenant détailler la démarche de conception que nous proposons.

    Reprendre les spécifications modifier

    En entrée de cette étape, nous disposons de spécifications d'un objet à concevoir. En sortie, nous devons produire des spécifications révisées. Un objet résultant d'une phase d'analyse précédente a été simplement défini spécifiquement, c'est-à-dire que l'on a identifié une certaine abstraction dans le but de résoudre un problème particulier. Celui qui a identifié l'objet en a nécessairement eu une vue étroite: celle limitée aux seules fonctionnalité nécessaires à la solution de son problème. La spécification qui nous sert en entrée doit donc être considérée comme un minimum à réaliser: elle correspond aux besoins immédiats de celui qui l'a définie.

    La première étape consiste à étudier le problème, c'est-à-dire analyser l'objet du monde réel dont l'objet à concevoir est une abstraction. Nous utiliserons toujours le terme «objet», bien qu'il puisse s'agir d'une classe, ou même parfois d'unités purement fonctionnelles. Afin de ne pas être influencé par cette vue particulière, il est bon d'ignorer a priori le contexte dans lequel l'objet sera utilisé. Si la taille de l'équipe le permet, il est préférable que la réalisation de l'objet soit confiée à une personne qui n'a pas participé à sa définition[1]. Le point important est de répondre à la question: cet objet à concevoir, qu'est-ce que c'est? Pour des objets de haut niveau, c'est-à-dire au début du projet, la réponse est loin d'être triviale et peut conditionner largement l'architecture de la solution. Il est bon alors de discuter du problème en réunion générale de toute l'équipe. On sera surpris de la différence de vues que des personnes différentes peuvent avoir d'objets apparemment simples.

    Par exemple, dans un système informatique d'entreprise, on identifiera la nécessité d'un objet «client». Il faudra se demander ce qu'est réellement un client. Dans un projet où nous avons été amené à intervenir, nous avons pu constater que le terme «client» recouvrait des entités tellement différentes que nous avons dû abandonner totalement cette notion au profit de plusieurs autres dont la définition était moins controversée, comme «responsable légal», «prospect», «destinataire de livraison»...

    Il faut conserver dans le cahier «historique» les minutes de ces réunions, avec les différentes idées qui y sont apparues. Il est fréquent qu'une direction rejetée lors de la discussion apparaisse plus tard comme une possibilité intéressante: l'archivage des discussions permet alors de repartir sur une nouvelle voie.

    Cette étape produit la spécification Ada définitive de l'objet et une fiche de description de l'objet qui présente ce que l'objet est et ce qu'il fait du point de vue abstrait, c'est-à-dire sans référence à une quelconque implémentation possible. Si des ajustements ont été nécessaires, cela peut avoir une influence sur le niveau d'abstraction précédent; en particulier, cela peut entraîner des modifications dans le corps des objets du niveau supérieur. Ceci est normal. Un grand danger de la conception est de figer trop tôt certaines décisions: on continue sur la même voie sous prétexte de ne pas perturber l'existant et bien souvent on s'aperçoit, mais beaucoup plus tard, que la modification est absolument indispensable et la perturbation risque d'être beaucoup plus importante. Il vaut donc mieux admettre qu'il existe des (petites) «oscillations» des spécifications à la frontière entre deux niveaux d'abstraction. L'important avec notre méthode est que ces perturbations sont limitées au niveau immédiatement supérieur et ne «percolent» pas dans toute la hiérarchie du projet.

    1. Ce qui n'empêche pas le réalisateur d'aller demander des précisions à ses «clients», voire même de négocier l'interface.

    Définir le plan d'abstraction modifier

    En entrée de cette étape, nous disposons des spécifications définitives d'un objet à concevoir. En sortie, nous devons produire le corps de cet objet et les spécifications des objets utilisés par ce corps. Rappelons que c'est l'ensemble constitué d'une implémentation (vue concrète du niveau N) et des spécifications des objets de plus bas niveau utilisés (vues abstraites du niveau N+1) que nous appelons un plan d'abstraction.

    L'objet à concevoir étant maintenant correctement défini, l'étape va consister à identifier les sous-objets qui le composent. Ceci conduit à élaborer une stratégie informelle d'implémentation. Bien entendu, diverses solutions sont possibles; il convient d'en envisager plusieurs afin d'être à même de faire un choix. C'est la phase de plus pure création intellectuelle, donc celle la moins susceptible d'une formalisation. L'expérience et l'exemple restent les meilleurs moyens pour mener à bien cette étape.

    Nous allons présenter les différents points importants dans l'ordre où normalement on les aborde. L'ordre séquentiel de la présentation ne doit pas être pris comme une obligation; en général, on se préoccupera plus ou moins simultanément de ces différents aspects, de même qu'il est impossible d'étudier séparément les différents objets du plan d'abstraction: l'analyse porte sur plusieurs d'entre eux simultanément.

    1. Identifier les objets
    2. Il s'agit ici d'identifier les sous-objets nécessaires à la réalisation de l'objet à concevoir. Dans les premières phases du projet, notamment au premier niveau, il est bon d'effectuer ce travail en groupe. Les participants proposent des stratégies permettant de réaliser les différentes fonctionnalités de l'objet à concevoir. Le concepteur principal note au tableau[1] les mots employés par le groupe qu'il juge significatifs ou intéressants. Ceci donne lieu à de nouvelles discussions, où l'on va chercher à remplacer des mots au tableau par d'autres jugés plus appropriés. Ce processus n'est pas une vaine querelle de termes, mais une phase extrêmement importante qui va permettre de préciser, puis d'isoler les futurs objets. Au bout d'un certain temps, quelques notions essentielles émergent: on va alors les identifier comme des objets potentiels et essayer de leur rattacher les opérations qui ont pu apparaître dans la discussion. On ne cherche pas à ce stade à définir complètement les objets (on le fera lors de l'itération suivante), mais surtout à les identifier pour la vue que l'on en a actuellement. Les opérations à effectuer par chaque objet peuvent surgir naturellement dans la discussion. Dans d'autres cas, certaines opérations peuvent apparaître sans être a priori rattachées à un objet; il faut dans ce cas déterminer à quel objet elles appartiennent. Plusieurs difficultés ou erreurs sont susceptibles d'apparaître à ce niveau. La première difficulté est de s'assurer que les objets identifiés correspondent bien à des entités logiques. S'il s'agit d'une abstraction d'un objet du monde réel (personne, terminal, régulateur...), cela ne pose pas de problèmes. Mais on introduit parfois des objets plus informatiques, qui courent le risque de ne pas être de vrais objets, mais simplement des noms utilisés pour baptiser des éléments de décomposition fonctionnelle. On se méfiera particulièrement des objets qui portent des noms d'action : «Gestionnaire de...», «Contrôleur», ... Le meilleur conseil à ce niveau est d'essayer, même pour des objets apparemment totalement informatiques, de trouver des analogues dans le monde réel et de s'accrocher à ces images. On évitera de parler d'une «table de relation prix-produit», ou d'un «driver d'interruption», pour dire plutôt objet «tarif» ou objet «clavier». Ensuite, il est possible d'identifier trop tôt des objets qui appartiennent en fait à des niveaux plus profonds. Le problème est grave, car il correspond souvent à des surspécifications. On a une idée précise de l'implémentation d'un objet et l'on confond l'objet avec son implémentation. Par exemple, si l'on veut transcoder des caractères (c'est-à-dire les faire passer d'un code, comme Latin-1, vers un autre, comme celui de l'IBM-PC), il paraît évident qu'il faut une table de transcodage. Évident ? Pas du tout ! Le besoin est celui d'une fonction de transcodage. Cette fonction peut être implémentée trivialement au moyen d'une table, mais ce n'est pas la seule façon de faire. La preuve: si nous traitions des Wide_Character (jeu de caractères 16 bits), il serait beaucoup trop coûteux d'effectuer le transcodage par une table. Enfin, il arrive fréquemment que l'on ne rattache pas les opérations aux bons objets. Remarquons à ce sujet la dissymétrie fondamentale de l'approche objet. Dans un modèle entités-relations, les opérations servent à relier les données, sans leur être attachées et sont fondamentalement bidirectionnelles. On dira indifféremment qu'un client ouvre un compte ou qu'un compte est ouvert par un client. En approche objet, nous devrons décider si Ouvrir est une opération du client ou du compte. Une technique qui fonctionne bien pour résoudre ce problème consiste à se poser la question «qu'est-ce qui change si...». Si une modification dans la nature d'un objet nécessite une modification de l'opération, alors il s'agit d'une opération de l'objet. Par exemple, pour ouvrir le compte nous pouvons supposer qu'il est nécessaire de passer la référence du client, le montant initial et le type du compte. Si nous décidons d'augmenter le nombre de caractères du nom du client, cela n'aura aucune influence sur la procédure d'ouverture elle-même. Si en revanche nous voulons modifier la notion de compte pour introduire des comptes à intérêt, il faudra passer un paramètre supplémentaire (le taux). Nous pouvons en déduire qu’ouvrir est une opération du compte et non du client. S'il était encore relativement facile de trouver la solution correcte dans l'exemple précédent, il faut être conscient que les fautes de rattachement sont courantes. Elles se traduisent souvent en Ada par des difficultés de compilation, notamment au niveau des types ou des visibilités. En particulier, si l'on a décidé de faire un type privé et que l'on s'aperçoit que des modules extérieurs ont besoin de voir sa structure interne, il faut sérieusement envisager la possibilité d'une erreur de rattachement avant de s'empresser de rendre public le type privé, ou d'utiliser des unités enfants pour y accéder. Retenons donc qu'une décision de conception à ce niveau ne saurait être définitive; il importe au contraire d'être prêt à remettre en question ce qui pouvait sembler acquis cinq minutes plus tôt. Il faut penser à noter dans les minutes de la réunion les raisons de ces changements, afin de prévenir d'éventuels remords ultérieurs dus à l'oubli des raisons de la marche arrière. En fin d'étape, on décrit les objets identifiés sous forme de spécifications Ada, que l'on compile pour fournir un premier niveau de vérification.
      1. Il est absolument indispensable qu'une salle de réunion pour ce genre d'activités dispose d'un tableau blanc (ou noir !).
    3. Écrire le corps des opérations de l'objet à concevoir
    4. Les composants étant spécifiés, il faut les assembler pour construire l'implémentation de l'objet d'origine. Concrètement, ceci signifie que nous pouvons écrire le corps en principe définitif de l'objet à concevoir avec toutes ses opérations. Ce corps sera compilé; un problème à ce niveau signifie généralement que les objets utilisés ne sont pas correctement définis. On ne s'étonnera donc pas de trouver ici encore quelques oscillations entre la définition des objets utilisés et l'implémentation des opérations de l'objet à concevoir. Noter que les corps sont des enchaînements d'actions, effectuées par les opérations des objets utilisés. Une analyse en programmation structurée est donc tout à fait appropriée pour décrire cette phase. On essayera d'éviter d'avoir à documenter ces implémentations; en effet, il s'agit d'une partie purement algorithmique et nous avons déjà dit combien nous préférions nous appuyer sur un code auto-documenté. Bien que cela représente un idéal parfois impossible (et souvent difficile) à atteindre, la simple lecture du code ne doit laisser que peu de doutes au lecteur qui aurait lu et compris la documentation de spécification des composants utilisés, ce qui est le minimum que l'on soit en droit d'exiger de lui. À l'issue de cette étape, on dispose d'un plan d'abstraction complètement compilé.
    5. Contester
    6. À ce stade, on a souvent une idée préconçue de l'implémentation des objets qui viennent d'être identifiés, souvent guidée par des expériences précédentes. Nous allons donc les soumettre «à la torture», afin de nous assurer qu'ils réagissent correctement.
    • Abstraction
    Première épreuve : imaginer des implémentations loufoques. Nous avons besoin d'une simple petite table de 10 éléments? Cela ne fait rien, imaginons si une implémentation sous forme de mémoire virtuelle avec pagination sur disque aurait une influence sur les spécifications. Le but n'est pas tant de permettre de réaliser vraiment ces implémentations «irréalistes» que de s'assurer de la bonne indépendance des spécifications par rapport à toute technique d'implémentation, c'est-à-dire que la spécification est réellement abstraite. Si ce n'est pas le cas, peut-être peut-on concevoir un objet de plus haut niveau, réellement abstrait, qui utiliserait l'objet en cours de revue pour son implémentation; il faut alors garder l'objet courant pour plus tard et définir ces objets de plus haut niveau que nous avions en quelque sorte court-circuités.
    • Réutilisabilité
    Deuxième épreuve : imaginer comment réagit la conception à des changements des exigences. Essayer d'imaginer de nouveaux services que pourrait demander le «client» (c'est-à-dire celui qui nous impose les spécifications de l'objet à définir). Les objets définis nous permettraient-ils de répondre à la demande?
      • Idéalement, il suffirait d'assembler les mêmes composants différemment, preuve que notre conception est à la fois générale et puissante. Éventuellement, il faudrait rajouter de nouveaux composants, qui ne perturberaient pas ceux que nous avons définis: on peut alors dire que le système est peut-être incomplet, mais extensible.
      • Moins bon: il faut généraliser les composants existants en leur rajoutant des fonctionnalités nouvelles; ne pourrait-on le faire tout de suite? Il est souvent plus simple de prévoir largement les services fournis au moment de la conception initiale que d'avoir à les augmenter ensuite.
      • Mauvais: il faut modifier le composant; une évolution risque alors de perturber non seulement le composant, mais également ses utilisateurs. On a alors un phénomène de diffusion de l'action d'évolution dans des parties du projet qui n'auraient pas dû être concernées. Il est impératif de remettre en cause la solution choisie.
    • Élémentarité
    Troisième épreuve: l'objet est-il élémentaire? Chaque objet doit être une abstraction complète et insécable: il prend en charge TOUT un aspect d'UNE SEULE entité. Il faut donc vérifier qu'il n'est pas possible de le séparer en deux objets distincts. En bonne logique, il faudrait aussi vérifier que deux modules séparés ne peuvent pas être réunis en un seul objet; en pratique, ce test est moins nécessaire, car lorsque ce problème surgit, il aboutit souvent à des impossibilités de compilation, diagnostiquées lors de l'étape précédente. On doit également se poser les questions suivantes: l'objet est-il simple? Correspond-il naturellement à un objet du monde réel ? À la limite, on devrait pouvoir prendre n'importe quel non-informaticien qui passe dans le couloir et lui expliquer ce qu'est l'objet.
  • Caractérisation
  • Nous allons maintenant nous livrer à une phase de caractérisation des objets utilisés, c'est-à-dire que nous allons essayer de voir comment ils se positionnent par rapport aux diverses classifications de la troisième partie de ce livre. Le but de cette étape est double: vérifier la vraisemblance du composant (un composant inclassable a toutes les chances de résulter d'une erreur de conception) et préciser la sémantique des objets. En particulier, nous devons vérifier si l'objet est clairement une machine abstraite ou un type de donnée abstrait. Dans ce dernier cas, nous devons préciser s'il est à sémantique de référence ou à sémantique de valeur. Il faut se demander si la notion qu'il recouvre est susceptible de donner naissance à une classe de plusieurs objets. Enfin un objet utilisé par plusieurs niveaux d'abstraction doit nous faire penser à un bus logiciel, susceptible de produire un composant réutilisable. Rappelons que parfois un composant échappera à la classification[1], mais que cela doit attirer l'attention du contrôle qualité qui devra s'assurer que cela ne dissimule pas une erreur de conception.
    1. Une pensée émue pour les naturalistes qui découvrirent l'ornithorynque...
  • Chercher à réutiliser
  • Maintenant que nous connaissons l'entité à réaliser, nous allons chercher dans l'existant, types du projet et bibliothèques de composants logiciels, s'il n'existe pas un composant que nous pourrions réutiliser au lieu d'avoir à en développer un nouveau. Bien entendu, il sera extrêmement rare de trouver exactement le composant adapté à nos besoins; s'il existe quelque chose de suffisamment proche, il faudra envisager d'adapter la stratégie de l'objet à concevoir pour permettre de réutiliser les composants existants. Si nous avons l'impression que l'abstraction nécessaire existe parmi nos composants logiciels, mais ne fournit pas les services nécessaires, il est possible que nous disposions du bon composant réutilisable, mais incomplet. On préférera alors enrichir le composant existant plutôt que d'en développer un nouveau, en prenant garde de conserver la nature «généraliste» du composant existant, même si ce n'est pas l'optimum pour notre problème particulier. Parfois, nous pourrons trouver que la similitude de l'objet désiré avec un objet existant provient de ce qu'ils appartiennent logiquement à une même famille; nous pourrons alors envisager de créer une classe plus générale dont nous ferions hériter l'ancien et le nouvel objet. Si enfin nous ne trouvons rien de satisfaisant, il faudra développer un nouveau composant. Dans ce cas, on prendra soin de définir un composant franchement différent de ceux qui existent déjà. Au besoin, on fera légèrement évoluer les spécifications pour les éloigner des objets existants. La raison de cette règle est simple: ou bien le nouveau composant correspond à la même abstraction que des composants existants et il faut faire converger les vues plutôt que de définir à nouveau; ou bien il s'agit d'une abstraction différente et il faut se méfier des effets de mimétisme qui font qu'un nouveau composant est influencé par les composants connus. Ce qu'il faut éviter absolument, c'est de développer plusieurs abstractions légèrement différentes du même objet. Si l'on commence dans cette voie, on aboutit à une prolifération incontrôlable de versions. Noter que la classification tend à officialiser cette prolifération en l'organisant (chacun développe sa propre version d'un composant, en ne recodant que la différence), alors que la composition tend à l'empêcher.

    Tester le plan d'abstraction modifier

    En entrée de cette étape, on dispose de la spécification du plan d'abstraction. Il faut produire les corps maquettes des objets utilisés pour tester le plan d'abstraction et servir de référence ultérieure pour la conception.

    La compilation du plan a permis de terminer l'implémentation de l'objet à concevoir et de la vérifier sur le plan syntaxique, ce qui donne déjà quelque espoir d'avoir évité les incohérences. Si le problème est suffisamment simple, on peut en rester là. Mais il faut souvent vérifier, au moins partiellement, le comportement dynamique du plan d'abstraction. Ceci s'obtient en écrivant des corps «maquettes» des objets utilisés grâce au paquetage ADPT. Une utilisation judicieuse des durées simulées sert à ajuster le «budget temps» des opérations. Cette maquette est conservée comme documentation dynamique de la façon dont le concepteur voyait le comportement de ses objets utilisés. Elle peut aussi servir à faire tourner l'ensemble du programme, notamment en cas de développement en équipe.

    Le projet global peut être pris comme programme de test: on intègre tout le plan d'abstraction, y compris ses sous-implémentations maquettes, et l'on regarde si cela fonctionne. Si tout va bien, c'est parfait. Mais si l'on découvre des erreurs, il peut être difficile de les piéger dans le contexte de l'ensemble du projet. Aussi est-il préférable d'écrire des programmes de tests unitaires pour chaque plan d'abstraction. Ces programmes seront conservés comme documentation et pour effectuer des tests de non-régression par la suite. Il est possible (et même préférable) d'écrire le test unitaire avant l'implémentation du module testé. On peut vérifier le test au moyen de l'implémentation maquette de l'unité que l'on est sur le point de réaliser effectivement. Ceci garantit l'aspect «boîte noire» du test et permet de se prémunir contre des déviations dues à l'implémentation (ce qui n'empêche pas d'enrichir le test par la suite, suite à la découverte d'erreurs qu'il n'avait pas initialement diagnostiquées). Bien sûr, le test peut faire découvrir des erreurs; les spécifications et les divers documents précédents peuvent donc encore évoluer.

    Documenter le plan d'abstraction modifier

    En entrée de cette étape, nous disposons d'une description opérationnelle du plan d'abstraction. Nous devons produire la documentation associée.

    1. Cahier des décisions de conception
    2. Ce cahier est lié à la notion d'implémentation. On note les éléments importants de la réalisation de l'objet à concevoir: stratégie informelle, raisons qui ont amené à définir les objets utilisés et choix stratégiques qui ont été faits. C'est là qu'il faut noter les fausses pistes éventuellement abandonnées, ainsi que les conseils pour l'évolution future, en particulier si on a prévu des possibilités d'évolution non réalisées dans cette version... et toute autre information utile pour le mainteneur futur, notamment les erreurs ou pièges à éviter. On peut inclure une représentation graphique du plan d'abstraction, comprenant au moins les dépendances logiques et éventuellement certains flots (de données, d'appels, d'exceptions) importants. Des diagrammes de HOOD (ou inspirés de HOOD) sont tout à fait appropriés pour cela.
    3. Documentation d'utilisation
    4. Si l'analyse critique de l'objet à concevoir a conduit à une modification des spécifications, il faut remettre à jour la documentation d'utilisation correspondante. On ajoute ensuite un chapitre pour chacun des objets utilisés. Rappelons que le but des descriptions est de fournir la représentation abstraite nécessaire à l'utilisateur du module qui ne connaît pas l'implémentation. L'information doit être concise et pertinente: ce qu'il faut savoir pour utiliser l'objet en question, ni plus, ni moins. Penser à spécifier le comportement en cas de paramètres anormaux: à qui incombe la charge des vérifications? Des exceptions peuvent-elles être levées? Il est inutile en revanche de détailler ce qui se déduit de la spécification Ada : si un paramètre a le sous-type Natural, la syntaxe exprime déjà que tout appel avec une valeur négative lèvera l'exception Constraint_Error.

    Itération modifier

    À ce point, les objets sont prêts à entrer à leur tour dans le cycle de réalisation. Rien n'oblige à les confier à ceux qui les ont définis. En fait, on augmentera les chances d'indépendance des modules en évitant que ceux qui définissent les objets les implémentent.

    On gère dans le projet une liste d'objets à implémenter: à l'issue d'une itération, les développeurs rajoutent les nouveaux objets à la fin de la liste et prennent comme nouveau travail la tête de liste. Une telle façon de faire constituera un excellent test de la compréhensibilité des spécifications!

    Troisième phase: récapitulation et analyse modifier

    Une particularité de notre méthode est qu'il n'y a pas du tout de phase d'intégration, ou plus exactement que l'intégration a lieu en continu tout au long du projet: depuis le début, le système est complet (vu de l'extérieur), même s'il est uniquement constitué de maquettes grossières. Chaque étape va faire descendre le niveau des maquettes jusqu'à ce qu'elles aient totalement disparu: à ce point la réalisation est terminée. Mais à tout moment, le projet est dans un état stable, complet et vérifié.

    Une fois le projet terminé, il est bon de se livrer à une récapitulation (debriefing) du projet. On en analyse les qualités et les faiblesses. Certains choix du début ont pu se révéler à l'usage non optimaux; le gain apporté par une autre solution n'était pas suffisant pour justifier un retour en arrière, mais il est bon pour l'avenir d'identifier les points sous-estimés lors du choix initial qui ont fait passer à côté d'une meilleure solution. On peut identifier des possibilités d'évolution pour une version ultérieure: des fonctionnalités qui n'étaient pas demandées, mais pourraient se révéler utiles plus tard; des modifications de structure qui n'ont pu être effectuées par manque de temps, mais que l'on pourrait entreprendre à l'occasion d'une évolution du logiciel, etc. Enfin, on se livre à une analyse de réutilisabilité. On cherche des modules développés de façon spécifique, mais qui seraient candidats à une généralisation ultérieure pour les transformer en composants réutilisables, selon le processus d'analyse a posteriori présenté dans la troisième partie de ce livre.

    Résumé de la méthode modifier

    Les phases de la méthode sont résumées dans le schéma de la figure 39.

     
    Figure 39: Schéma des phases de la méthode
    Figure 39: Schéma des phases de la méthode

    Critique de la méthode modifier

    Il n'existe pas de méthode universelle, mais seulement différentes formes de compromis, qui peuvent se révéler plus ou moins adaptés aux besoins. Que peut-on dire de la méthode que nous venons de présenter?

    C'est une méthode orientée objet par composition, qui autorise des hiérarchies de types au moment du choix des représentations, mais où l'héritage n'a qu'une faible importance. Elle s'applique bien aux projets manipulant de nombreuses entités du monde réel; elle peut être moins adaptée à ceux essentiellement procéduraux, comme des compilateurs ou des modélisations mathématiques, pour lesquels des techniques de programmation structurée sont plus adaptées. Elle accorde la plus grande attention à la découpe en couches soigneusement isolées, grâce à la notion de plan d'abstraction. Ceci autorise une technique d'implémentation par démaquettage progressif, qui fait disparaître la phase d'intégration en tant que telle.

    Le langage Ada est utilisé comme moyen d'expression des décisions de conception à toutes les phases du projet. Ceci peut surprendre en donnant l'impression de «coder» tout de suite. Le but est d'avoir un langage d'expression unique depuis la conception jusqu'à la réalisation; cela n'est possible que grâce au très haut niveau d'abstraction fourni par Ada. L'utilisation de la méthode avec un langage de plus bas niveau serait difficile, voire dangereuse, car il ne serait pas possible d'exprimer des décisions de conception sans faire en même temps des décisions d'implémentation – ce que l'on veut éviter.

    La méthode essaie de limiter la quantité de documentation, notamment en fournissant autant que possible du code auto-documenté. Ceci n'est possible que parce que la méthode ne vise que les projets de taille moyenne; cette politique peut ne pas être applicable à des projets de taille plus importante. Enfin, la méthode ne formalise pas les contraintes temporelles au-delà de la possibilité de gérer un «budget temps». Elle n'est donc pas adaptée au développement de projets temps réel critiques.

    En résumé, son domaine d'efficacité maximum est celui de la réalisation d'applications informatiques courantes, sans contraintes de certifications ni de performances trop exigeantes et de taille moyenne... ce qui représente tout de même une bonne part des développements logiciels.

    Exercices modifier

    1. Comparer la méthode que nous venons d'exposer à HOOD et OMT en tenant compte des différences de domaines visés.
    2. Rechercher les points de la méthode qui sont spécifiques d'Ada et envisager comment l'utiliser avec d'autres langages.

    Exemple d'utilisation de la méthode modifier

    Ce chapitre décrit un exemple d'utilisation de la méthode que nous venons de définir. Généralement, les exemples publiés dans les «bons ouvrages» proposent directement la solution parfaite, avec beaucoup d'explications montrant comment on l'a obtenue naturellement. En fait, la conception de logiciel, comme toute activité créatrice, comporte des tâtonnements et des erreurs. Nous avons essayé dans cet exemple de suivre la conception d'une application en n'occultant pas les erreurs initiales, mais au contraire en les montrant et en expliquant comment elles ont été identifiées et corrigées pour aboutir à la solution. De même, nous avons montré les hésitations que nous avons éprouvées lors de choix entre plusieurs solutions et essayé d'expliciter le processus qui nous a amené à nos décisions de conception. Il importe donc de lire le chapitre en entier avant de décider que l'auteur raconte n'importe quoi... En revanche, nous invitons le lecteur à réfléchir par lui-même aux solutions possibles à chaque étape avant de lire celles que nous proposons. Peut-être en trouvera-t-il qui ne figurent pas ici; qu'il n'en déduise pas que les siennes sont mauvaises, mais simplement que cela montre la richesse des choix possibles et la nécessité de réfléchir avant de se précipiter sur la solution «évidente».

    D'autre part, les contraintes propres à un livre nous empêchent de fournir tous les documents et de suivre précisément toutes les étapes qui sont nécessaires à un développement industriel. Que le développeur industriel n'en profite pas pour croire qu'ils sont moins nécessaires que le reste!

    Phase initiale modifier

    Comprendre le problème modifier

    La célèbre société «L'Automatique de Distribution Alimentaire» (A.D.A.) nous a chargé de réaliser le logiciel de contrôle d'un distributeur automatique de boissons. L'appareil distribue du café (avec ou sans lait, avec ou sans sucre), du thé, des boissons froides... Il accepte toutes les pièces et rend la monnaie. Les différentes boissons n'ont pas forcément le même prix. Le logiciel doit permettre de changer aisément le tarif des boissons et être conçu de façon réutilisable pour éviter de trop gros changements en cas d'évolution des éléments matériels sous-jacents.

    Nous notons qu'un tel cahier des charges est «ouvert»: tout le monde connaît ce genre d'appareil, aussi ne nous a-t-on fourni que peu de détails sur les contraintes. Bien que ceci nous laisse relativement libre, c'est par nature dangereux: la quantité de «non-dit culturel» est maximale et notre vue risque de ne pas correspondre à celle du client. Dans un projet réel, il faudrait prévoir une réunion avec le client à la fin de la phase de conception initiale, afin de vérifier que la structure adoptée correspond bien à celle attendue.

    Choix fondamentaux modifier

    Bien entendu, un exemple livresque ne saurait avoir les mêmes contraintes qu'un projet industriel. Disons que le contexte de développement correspondrait aux choix fondamentaux suivants:

    • Utilisation de la méthode de développement C-FOOD.
    • Pas de composants logiciels préexistants et volonté du client d'obtenir la totalité des sources: nous devrons donc développer nous-même tout l'ensemble et non rechercher d'éventuels composants commerciaux.
    • Le logiciel doit être robuste (aucun opérateur humain), sans cependant être critique du point de vue de la sécurité. En particulier, une politique d'erreurs par exceptions simples est suffisante.

    Analyse de premier niveau modifier

    Une première discussion du problème aboutit à la formulation suivante:

    Le logiciel doit piloter un distributeur[1] de boissons en fonction du montant introduit par le consommateur dans le monnayeur. Le consommateur choisit sa boisson en appuyant sur l'un des boutons de l'appareil. Chaque boisson a un prix et rien n'indique a priori que toutes les boissons doivent avoir le même prix.
    1. Les mots en caractères gras sont ceux qui peuvent être candidats au statut d'objets.

    Définir le plan d'abstraction modifier

    1. Identifier les objets utilisés
    2. À partir de la définition informelle précédente, nous allons essayer d'identifier les principaux objets utilisés. Le problème comporte à l'évidence une notion centrale qui est la boisson. Il convient cependant d'éviter de se précipiter vers une définition trop hâtive; comme cet élément est très important, de nombreuses vues sont possibles et nous ne maîtrisons pas encore assez bien le problème pour la définir. Retenons simplement pour l'instant qu'il existe «quelque chose» appelé boisson. Nous devrons gérer de l'argent, qui est manifestement une grandeur d'un type numérique approprié. Le consommateur est évidemment un objet important du monde réel. Toutefois, il est totalement extérieur au système. Le consommateur n'est pas géré par le logiciel et il n'interagit pas non plus directement avec le logiciel. Ce n'est donc pas un objet intéressant pour notre vue actuelle.Le monnayeur se chargera de tout ce qui est lié à la gestion de l'argent: décompte du montant introduit et rendu de monnaie. Nous avions remarqué le mot bouton dans la description informelle. Il n'apparaît pas nécessaire cependant d'en faire un objet pour l'instant, puisque les objets précédemment définis semblent suffisants. Le bouton est un objet de première importance pour la vue du consommateur (ce qui nous l'a fait mettre dans la définition informelle). Il ne s'agit que d'un détail de bas niveau pour la vue du logiciel. Pour s'en convaincre, il suffit de remarquer que l'on pourrait remplacer les boutons par un contacteur rotatif... ou des messages provenant d'un réseau local sans que cela change quoi que ce soit au logiciel à ce niveau. Pourtant, si ce terme figure dans l'énoncé, cela signifie qu'il cache une notion plus importante... Réfléchissons à la nature du problème. Le bouton est le moyen utilisé par le consommateur pour faire son choix. Le programme doit être informé du choix de boisson du consommateur, du prix correspondant et peut-être d'autres éléments non encore identifiés. Nous pouvons formaliser ceci en introduisant un objet menu qui sera chargé de gérer les choix du consommateur. Nous allons maintenant revenir sur chacune des notions précédentes, en essayant de préciser un peu mieux leurs définitions, d'identifier leurs opérations et de tenter une première approximation Ada de leurs spécifications.
    • Argent
    L'argent est un type numérique comportant des francs et des centimes. Les seules opérations nécessaires a priori sont l'addition et la soustraction. Nous en faisons cependant un paquetage autonome, car cela correspond à une notion indépendante du reste du problème. Un type décimal est particulièrement adapté et comporte les opérations nécessaires. Le type Argent est donc un objet (ou plutôt un type) terminal du projet. Quelles limites devons-nous utiliser pour ce type? Actuellement, la plus grosse pièce courante en France est la pièce de 20F, mais il ne paraît pas impossible de voir un jour apparaître des pièces de 50 ou même 100F. La prudence nous recommande donc de prévoir cinq chiffres significatifs (avec les centimes).
    package Définition_Argent is
    	type Argent is delta 0.01 digits 5;
    end Définition_Argent;
    
    • Boisson

    Tout d'abord, nous nous apercevons qu'à ce point de l'analyse, rien n'est spécifique des produits liquides; nous pourrions aussi bien distribuer des cacahuètes... Nous allons donc remplacer le terme boisson par produit.

    Qu'est-ce qui caractérise un produit? Il lui est associé un prix et chaque produit a une façon bien particulière d'être fourni. Ce peut être élémentaire (cas du paquet de cacahuètes: il suffit d'ouvrir un relais pour laisser tomber le paquet), mais d'autres doivent être réellement fabriqués; pour un café par exemple, il faut laisser tomber de la poudre de café (et éventuellement de la poudre de lait), du sucre, une petite cuillère, une quantité plus ou moins grande d'eau... Deux solutions sont possibles à ce niveau:

      • Nous considérons que la notion de produit est une classe générale, munie d'un attribut «prix» et d'une méthode de fabrication propre à chaque élément de la classe. Comme aucun produit réel ne peut être un «produit» sans être quelque chose de plus précis, cette classe doit être abstraite. C'est une solution typiquement « classification » : nous créerons les produits réels grâce au mécanisme d'héritage à partir de la classe Produit. Nous exprimerions cette solution de la façon suivante :
    with Définition_Argent; use Définition_Argent;
    package Classe_Produit is
    	type Produit is abstract tagged record
    		Prix : Argent;
    	end record;
    	procedure Fabriquer (Le_Produit: Produit) is abstract;
    end Classe_Produit;
    
      • Nous considérons que le produit n'est qu'un identifiant auquel sont associés de façon extérieure un tarif (qui fournit le prix de chaque produit) et une recette (qui définit comment fabriquer le produit). Une telle approche ne fait pas appel aux notions de la classification ni à l'héritage.

    Quelle est la meilleure solution? Ce n'est pas évident. Remarquons que chaque solution conduit à une factorisation, mais pas à la même: dans la première, tous les aspects liés à une boisson (prix, fabrication) sont regroupés, alors que dans la seconde on rassemble d'une part tout ce qui est tarif, d'autre part tout ce qui est fabrication.

    Il ne semble pas que la gestion du prix soit un facteur déterminant: l'implémentation sera triviale dans tous les cas de figure; nous allons donc réfléchir au problème de la fabrication. La première solution nous permet d'avoir simultanément des produits dont la méthode de fabrication est radicalement différente: elle n'implique aucune ressemblance entre les différentes méthodes Fabriquer, alors que la seconde implique que la fabrication de tous les produits s'exprime au moyen du même genre de «recette». Un avantage de la première solution est donc qu'une évolution du logiciel conduisant à distribuer un produit radicalement différent ne perturbera pas les produits existants. Cependant, un changement des produits distribués nécessiterait une reprogrammation: si nous nous apercevons que personne n'achète le jus de citron et que nous décidons de vendre de la soupe à la place, il faudra faire une nouvelle version du logiciel. Si au contraire nous adoptons la seconde solution, il suffit de changer quelques tables, en modifiant une PROM par exemple, ou même simplement en branchant un terminal pour entrer la nouvelle recette. De même, un changement de tarif est plus facile si tous les prix sont rassemblés, plutôt que dispersés dans chacun des produits. D'autre part, il paraît peu probable que la souplesse d'évolution offerte par la première solution soit nécessaire: un changement radical de méthode de fabrication signifierait de nouveaux dispositifs matériels, donc quasiment la construction d'un appareil entièrement nouveau. Ce n'est pas susceptible de se produire souvent, comparé au fait de changer les produits distribués. Enfin, quiconque a déjà vu un distributeur ouvert a certainement remarqué qu'il comporte une entité, l'unité de fabrication, qui confectionne effectivement les produits, bien séparée du monnayeur qui gère l'argent. Dans une optique orientée objet, il paraît préférable que la découpe du logiciel corresponde aux éléments matériels.

    Nous choisirons donc la seconde solution, mais les raisons qui nous y ont amené devront être soigneusement consignées dans le document de maintenance. Nous notons que nous identifions alors trois nouveaux objets: le tarif, la recette et l'unité de fabrication. La notion de produit devient alors extrêmement ténue: elle ne sert qu'à faire la liaison entre un bouton sur lequel on appuie, un prix et une recette. Nous pouvons la représenter par n'importe quel type discret. La tendance naturelle en Ada serait d'utiliser un type énumératif :

    type Produit is (Café, Thé, Orange, Soda);
    

    Cette solution a nécessairement un aspect limitatif: les boissons doivent être choisies à la compilation et tout changement nécessite une recompilation de tout le système... ce que nous cherchons précisément à éviter. Revenons un peu sur ce qu'est un produit pour la vue que nous en avons maintenant. C'est finalement une notion qui nous permet de faire la liaison entre un bouton poussé par un utilisateur et une recette préparée par l'unité de fabrication. Le «parfum» choisi est sans importance pour nous! Ce que nous appelons Produit n'est qu'un numéro de bouton sur le panneau de contrôle. À un bouton est associée une recette (et un prix) et la notion de Produit devient un simple identifiant neutre. Type énumératif, type entier, que vaut-il mieux choisir? Difficile à dire à ce niveau, tout au plus pouvons-nous nous dire qu'il doit s'agir d'un type discret. Comme nous n'avons pas vraiment de critère de choix, mais que les raffinements ultérieurs pourront peut-être nous guider, il suffit d'exprimer l'état de nos réflexions en déclarant :

    with ADPT;
    package Définition_Produit is
    	type Produit is new ADPT.Type_Discret;
    end Définition_Produit;
    

    Nous n'avons a priori besoin d'aucune opération sur les produits (on ne va pas, par exemple, additionner deux produits). Comme pour l'argent, nous en faisons un paquetage séparé, car même s'il ne s'agit après tout que d'une simple déclaration de type, la notion qu'elle recouvre est importante.

    • Unité de fabrication

    L'unité de fabrication est une sorte de machine stupide, à qui l'on dit de confectionner un produit... et qui le fait. Elle n'a pas à se préoccuper du qui ni du quoi (comme de savoir si le consommateur a payé). Inversement, elle doit nous permettre d'ignorer totalement (à ce niveau) comment on fait pour fabriquer le produit. Du point de vue des spécifications, ceci s'exprime comme :

    with Définition_Produit; use Définition_Produit;
    package Unité_Fabrication is
    	procedure Confectionner (Le_produit : Produit);
    end Unité_Fabrication;
    

    Avec cette vue il ne peut s'agir que d'une machine abstraite. Doit-elle être active? Il est clair que le dispositif physique peut très bien évoluer en parallèle. Mais en pratique, pendant que la machine confectionne un produit, elle reste totalement bloquée et n'accepte aucune autre opération. Dans ces conditions, mieux vaut la laisser passive.

    • Monnayeur

    Le monnayeur gère tout ce qui est lié à l'argent. Il doit pouvoir nous indiquer le montant entré par le consommateur. Il doit également pouvoir rendre la monnaie sur le prix d'un produit. Notons que le monnayeur doit être actif en permanence, car le consommateur peut introduire des pièces à n'importe quel moment[1]. Il s'agira donc vraisemblablement d'une machine abstraite active.

    with Définition_Argent;  use Définition_Argent;
    with Définition_Produit; use Définition_Produit;
    package Monnayeur is
    	function  Montant_Introduit return Argent;
    	procedure Rendre_Monnaie (Du_produit: Produit);
    end Monnayeur;
    
    • Menu

    Le menu nous indique le produit choisi par le consommateur. Il gère les choix de l'utilisateur et ne renvoie qu'un choix valide. Ceci s'exprime comme :

    with Définition_Produit; use Définition_Produit; 
    package Menu is
    	function Choix_Consommateur return Produit;
    end Menu;
    
    • Tarif

    Le tarif nous indique le prix de chaque produit:

    with Définition_Produit; use Définition_Produit; 
    with Définition_Argent;  use Définition_Argent; 
    package Tarif is
    	function Le_Prix (De : Produit) return Argent;
    end Tarif;
    

    Ne peut-on en faire directement une table? Certainement, mais alors nous sommes lié au choix final du type de Produit. Si pour une raison ou une autre nous décidons que Produit n'est pas un type discret (ce pourrait être un type accès par exemple), il nous sera beaucoup plus facile d'évoluer si nous gardons le tarif sous la forme d'une fonction. Inversement, si nous implémentons la fonction au moyen d'une table, il suffira de mettre la fonction «en ligne» pour ne payer aucune pénalité d'efficacité[2]. Décider à ce niveau d'utiliser une table serait un exemple typique de surspécification – spécifier à partir d'une implémentation particulière, au lieu d'abstraire le besoin réel.

    1. Dans un système bien fait, le consommateur doit pouvoir introduire une pièce, puis appuyer sur «Café» et enfin introduire sa deuxième pièce. Il est très difficile d'obtenir ce comportement sans parallélisme... d'ailleurs certains distributeurs ne l'autorisent pas.
    2. Cette dernière remarque est pour le principe. Il est très peu vraisemblable que nous ayons un problème d'efficacité à ce niveau.
  • Ecrire le corps de l'objet à concevoir
  • Dans la phase initiale, l'objet à concevoir est simplement le programme principal. Comme tout programme principal Ada, il s'agit d'une simple procédure sans paramètres.
    with Définition_Produit, Définition_Argent, Menu,
         Monnayeur,          Unité_Fabrication, Tarif;     
    use  Définition_Produit, Définition_Argent, Menu,
         Monnayeur,          Unité_Fabrication, Tarif;
    procedure Principal is
    	Choisi : Produit;
    begin
    	loop
    		Choisi := Choix_consommateur;
    		while Montant_Introduit < Le_Prix (De => Choisi) loop
    			delay 0.1;
    		end loop;
    		Rendre_Monnaie (Du_produit => Choisi);
    		Confectionner (Le_Produit => Choisi);
    	end loop;
    end Principal;
    
  • Contester
  • Maintenant que nous avons une première structure sur laquelle appuyer notre pensée, il faut regarder de plus près et inspecter si nous n'avons pas omis des détails, manqué de précision dans les définitions, ou oublié une fonctionnalité. En particulier, nous n'avons pas envisagé les cas exceptionnels: épuisement des produits, non-disponibilité de monnaie, annulation de la demande par l'utilisateur. De plus, le programme principal effectue une attente active, ce qui est toujours gênant. Nous avons eu la prudence d'introduire un délai pour éviter des blocages sur un système monoprocesseur, mais une solution sans attente active serait préférable.
    • Cas du produit épuisé
    Nous avons dit que le menu doit toujours renvoyer un choix valide, c'est-à-dire qu'il ne doit pas prendre en compte un produit épuisé. Au premier niveau, il est commode de le spécifier ainsi, mais il faut quand même se poser la question de la faisabilité de cette spécification. Autrement dit: comment le menu peut-il savoir quels produits peuvent être fabriqués? Ou bien le menu peut interroger l'unité de fabrication, ou bien il faut que «quelqu'un» prévienne le menu qu'un produit n'est plus disponible. La première solution introduit un couplage supplémentaire entre des unités qui n'ont aucune raison de se connaître, aussi vaudrait-il mieux l'éviter. La seconde implique de déterminer qui doit prévenir le menu. Ce ne peut être que l'unité de fabrication (mais on introduit de nouveau un couplage avec le menu) ou le programme principal. Comme celui-ci connaît de toute façon le menu et l'unité de fabrication, on n'introduirait pas de couplage supplémentaire. Faut-il introduire une fonctionnalité supplémentaire pour demander à l'unité de fabrication si un produit peut être confectionné? La détermination risque d'être très difficile: si l'on peut vérifier que les différents ingrédients nécessaires sont disponibles, ce n'est quand même pas une garantie que le produit peut être réalisé. Un réservoir de poudre peut être en panne, un robinet collé... Nous devons donc de toute façon nous attendre qu'un produit ne puisse être réalisé: il faut prévoir une exception dans l'unité de fabrication.
    with Définition_Produit; use Définition_Produit;
    package Unité_Fabrication is
    	procedure Confectionner (Le_produit : Produit);
    	Confection_Impossible : exception;
    end Unité_Fabrication;
    

    Du coup, cela résout le problème précédent: le programme principal tente la fabrication; en cas d'échec, il invalide le produit. Nous ajoutons donc une fonctionnalité au menu pour invalider le produit; par symétrie, il nous faut également une fonctionnalité pour le revalider :

    with Définition_Produit; use Définition_Produit; 
    package Menu is
    	function Choix_Consommateur return Produit;
    	procedure Invalider (Le_produit : Produit);
    	procedure Revalider (Le_produit : Produit);
    end Menu;
    
    • Cas de l'annulation

    À tout moment (tout au moins tant que la distribution n'a pas commencé), le consommateur peut annuler sa commande. Nous n'avons encore rien prévu pour le gérer. Remarquons que ce cas n'était pas prévu au cahier des charges; dans une optique industrielle, il faudrait prévenir le client afin de vérifier que notre interprétation du comportement souhaité correspond bien à ses désirs.

    Une première solution serait de considérer l'annulation comme un produit spécial (et de prix nul). Si l'utilisateur appuie sur le bouton d'annulation avant d'avoir fait son choix, le consommateur «recevra» le produit Annulation. Rendre_Monnaie remboursera le consommateur (puisque le prix est 0.00) et l'unité de fabrication ignorera simplement le produit. Cette solution peut paraître astucieuse[1], mais elle ne permet pas de traiter simplement le cas de l'annulation après que le consommateur a fait son choix[2]. On pourrait également lever une exception au lieu de renvoyer une valeur spéciale, mais cela ne résoudrait pas le problème. Il faut prendre du recul, oublier l'informatique et revoir la question au niveau du domaine de problème.

    Remarquons qu'il existe un «point de non-retour» dans les actions du consommateur: lorsqu'il a choisi une boisson et que le montant introduit est suffisant, la distribution commence et il n'est plus possible de l'annuler. L'annulation peut intervenir à n'importe quel moment avant ce point et doit immédiatement stopper tout traitement en cours pour ramener le distributeur à l'état initial (après avoir rendu d'éventuelles pièces déjà introduites). On pourrait être tenté de modéliser ce comportement par une interruption, mais cela nous ferait dépendre du matériel dès ce niveau. De plus, comment interrompre le reste des traitements une fois l'interruption matérielle traitée? Certes on peut rajouter des booléens testés périodiquement, mais tout ceci commence à faire terriblement «bricolé». Heureusement, Ada nous procure une fonctionnalité correspondant de près au comportement souhaité: le transfert de contrôle asynchrone. Nous représenterons le bouton d'annulation comme une entrée et la séquence annulable comme la suite d'instructions avortables. Utiliserons-nous une entrée de tâche ou de type protégé? Nous n'avons aucune raison d'introduire de tâche à ce niveau, nous prendrons donc plutôt un objet protégé, comme la barrière que nous avons donnée en exemple dans le préambule du livre. Cette décision pourra être remise en cause ultérieurement.

    Qui doit gérer cet objet ? À priori, il correspond à un bouton de la face avant du distributeur, donc il devrait faire partie du menu. D'autre part, sur les distributeurs réels, le bouton d'annulation se situe plutôt du côté du monnayeur. Enfin nous pouvons toujours en faire une entité indépendante... Mettons-le pour l'instant dans le menu, mais ne soyons pas étonné si la suite de l'analyse vient à le déplacer. Cela nous donne :

    with Définition_Produit; use Définition_Produit; 
    package Menu is
    	function Choix_Consommateur return Produit;
    	procedure Invalider (Le_produit : Produit);
    	procedure Revalider (Le_produit : Produit);
    	protected Annulation is
    		entry Signalement;
    		procedure Demande;
    	private
    		Ouverte : Boolean := False;
    	end  Annulation;
    end Menu;
    

    Cette notion de point de non-retour est également importante pour le monnayeur: si l'utilisateur continue d'introduire des pièces, elles doivent être ignorées. On va donc avoir un changement d'état dans le monnayeur: dans un premier temps, on attend d'avoir atteint au moins le montant du produit. Une fois ce montant atteint, on fait passer le monnayeur dans un état «bloqué» où il rejette systématiquement toute pièce introduite. Ce comportement peut être obtenu par logiciel, mais au fond il vaut mieux le faire par matériel: il suffit de piloter un clapet qui laisse retomber directement les pièces introduites dans le gobelet de rendu de la monnaie. Ce clapet est au repos dans l'état «bloqué», de façon à ne pas voler l'utilisateur en cas de panne de courant. Il faut évidemment introduire un moyen de faire repasser le monnayeur dans l'état «actif». De plus, il faut pouvoir demander au monnayeur de rendre toutes les sommes introduites. Notre spécification va donc devenir :

    with Définition_Argent;  use Définition_Argent;
    with Définition_Produit; use Définition_Produit; 
    package Monnayeur is
    	procedure Activer;
    	procedure Bloquer;
    	function  Montant_Introduit return Argent;
    	procedure Rendre_Monnaie (Du_produit : Produit);
    	procedure Rembourser;
    end Monnayeur;
    
    1. Ce qui n'est pas forcément un gage de qualité.
    2. Notons une décision implicite : lorsque le client a fait son choix, il ne peut plus changer d'idée en appuyant sur un autre bouton (sauf annulation). Donc, le menu est bloqué après avoir reçu un choix valide. Donc il faut prévoir un moyen de le débloquer... Nous sommes en train d'étudier un autre problème, mais il faut noter celui-ci pour ne pas l'oublier par la suite.
    • Cas de la monnaie épuisée

    Qu'appelle-t-on monnaie épuisée? Les distributeurs qui rendent la monnaie disposent tous du voyant correspondant, mais sa signification n'est pas du tout évidente. En effet, le monnayeur peut ne pas être capable de garantir le rendu de monnaie dans tous les cas, tout en étant capable de le faire dans certains cas... Il faut séparer le problème du voyant de celui du rendu effectif. En dessous d'un certain nombre de pièces, le monnayeur allume automatiquement le voyant. Ceci est interne au monnayeur et ne nous concerne pas pour l'instant. Si maintenant le monnayeur est dans l'incapacité de rendre la monnaie qui lui a été demandée, il doit lever une exception (appelons-la Rendu_Impossible). Cette exception peut être rattrapée par le logiciel appelant qui prendra les mesures nécessaires, comme d'annuler la demande et de demander de rendre la monnaie avec un montant égal à la somme entrée... ce qui est évidemment toujours possible. La spécification du monnayeur devient :

    with Définition_Argent;  use Définition_Argent;
    with Définition_Produit; use Définition_Produit; 
    package Monnayeur is
    	procedure Activer;
    	procedure Bloquer;
    	function  Montant_Introduit return Argent;
    	procedure Rendre_Monnaie (Du_produit : Produit);
    	procedure Rembourser;
    	Rendu_Impossible : exception;
    end Monnayeur;
    

    Par analogie, nous allons rajouter une procédure Activer au Menu pour autoriser les choix de l'utilisateur, comme nous l'avions noté précédemment :

    with Définition_Produit; use Définition_Produit; 
    package Menu is
    	procedure Activer;
    	function Choix_Consommateur return Produit;
    	procedure Invalider (Le_produit : Produit);
    	procedure Revalider (Le_produit : Produit);
    	protected Annulation is
    		entry Signalement;
    		procedure Demande;
    	private
    		Ouverte : Boolean := False;
    	end  Annulation;
    end Menu;
    
    • Autres cas exceptionnels

    Nous ne voyons pas a priori d'autres cas d'exception; mais si un problème imprévu se produisait, il faut en tout état de cause rendre son argent à l'utilisateur. Il convient donc de prévoir un traitement d'exception «rattrape-tout».

    • Le problème de l'attente active

    On appelle «attentes actives» les boucles qui ne font qu'attendre qu'un événement se produise, comme dans le cas de la boucle d'attente sur le montant introduit. Une telle boucle est toujours gênante, car elle accapare l'unité centrale sans rien faire d'utile. Il faut toujours mettre un délai dans ce cas, autrement cette boucle pourrait empêcher d'autres parties du programme de s'exécuter... y compris celles chargées de changer la condition de boucle. Mais la meilleure solution consiste à les éviter. Pour cela, il faut se poser la question suivante: quel est le besoin de plus haut niveau qui nous a conduit à cette boucle? En l'occurrence, c'est d'attendre qu'un certain montant soit introduit. Attendre le prix d'un produit pourrait parfaitement être un service rendu par le monnayeur, faisant ainsi disparaître l'attente active, au moins à ce niveau d'abstraction. L'implémentation la fera peut-être réapparaître, à moins que nous ne trouvions une autre solution. Au moins, en reportant ce problème vers les couches plus profondes, ouvrons-nous la possibilité d'autres solutions. La spécification finale du monnayeur devient donc :

    with Définition_Argent;  use Définition_Argent;
    with Définition_Produit; use Définition_Produit;
    package Monnayeur is
    	procedure Activer;
    	procedure Bloquer;
    	procedure Attendre (Pour_Produit : Produit);
    	function  Montant_Introduit return Argent;
    	procedure Rendre_Monnaie (Du_produit: Produit);
    	procedure Rembourser;
    	Rendu_Impossible : exception;
    end Monnayeur;
    
  • Nouvelle version du programme principal
  • Nous pouvons maintenant récrire le programme principal en fonction des modifications que nous avons apportées :
    with Définition_Produit, Définition_Argent, Menu,
         Monnayeur,          Unité_Fabrication, Tarif;
    use  Définition_Produit, Définition_Argent, Menu,
         Monnayeur,          Unité_Fabrication, Tarif;
    procedure Principal is
    	Choisi : Produit;
    	Annulé : Boolean;
    begin
    	loop
    		Menu.Activer;
    		Monnayeur.Activer;
    		select
    			Menu.Annulation.Signalement;
    			Annulé := True;
    		then abort
    			Choisi := Choix_consommateur;
    			Attendre (Pour_Produit => Choisi);
    			Annulé := False;
    		end select;
    		Monnayeur.Bloquer;
    		if Annulé then
    			Rembourser;
    		else
    			begin
    				Rendre_Monnaie (Du_Produit => Choisi);
    				Confectionner (Le_Produit => Choisi);
    			exception
    				when Rendu_Impossible =>
    					Rembourser;
    				when Confection_Impossible =>
    					Invalider (Le_Produit => Choisi);
    					Rembourser;
    			end;
    		end if;
    	end loop;
    exception
    	when others =>
    		Monnayeur.Bloquer;
    		Rembourser;
    end Principal;
    

    Tester le plan d'abstraction modifier

    Nous avons maintenant un schéma général qui paraît satisfaisant. Les spécifications que nous venons d'établir peuvent être compilées pour vérifier qu'elles sont cohérentes. Nous pouvons vérifier dynamiquement le plan d'abstraction en fournissant des corps maquettes tels que :

    with ADPT; 
    package body Unité_Fabrication is
    	procedure Confectionner (Le_produit : Produit) is
    		use ADPT;
    	begin
    		ADPT_Action ("Confection du produit " &
    		            Produit'IMAGE (Le_produit));
    	end Confectionner;
    end Unité_Fabrication;
    

    On trouvera en annexe les corps maquettes des autres paquetages. On obtient ainsi une première version exécutable modélisant le comportement du programme à haut niveau, sans avoir encore décidé des modalités de réalisation des couches inférieures.

    Documenter le plan d'abstraction modifier

    Sans fournir une documentation aussi complète que l'exigerait un produit industriel, nous allons résumer les caractéristiques de la solution obtenue.

    Le distributeur est constitué de quatre objets principaux: l'unité de fabrication qui s'occupe de la fabrication effective des produits, le monnayeur qui gère tout ce qui est lié à la notion d'argent, le menu qui s'occupe de tout ce qui est dialogue avec l'utilisateur et le tarif qui relie les produits à leur prix. La figure 40 schématise cette architecture. Remarquer que nous avons représenté les produits et l'argent sous forme de bus logiciels: la structure réelle est ainsi beaucoup plus apparente. On remarque également qu'il n'existe aucune dépendance entre les quatre modules principaux; ceci correspond bien au fait qu'ils constituent des sous-systèmes indépendants, dont il serait aisé de confier le développement à des équipes séparées. Cette indépendance provient évidemment du fait que ces objets correspondent directement à des sous-systèmes physiques de l'appareil, qui sont eux-mêmes indépendants.

     
    Figure 40: Architecture du distributeur de boissons
    Figure 40: Architecture du distributeur de boissons

    Deuxième itération modifier

    Arrivé à ce stade, nous avons une définition satisfaisante, vérifiée, de tous les objets au premier niveau d'abstraction; il faut maintenant les reprendre un à un et les implémenter effectivement, c'est-à-dire examiner de façon détaillée comment on peut les réaliser. Les objets que nous avons empilés (c'est-à-dire ceux pour lesquels nous ne disposons pas encore de l'implémentation définitive) sont :

    • Le produit
    • Le monnayeur
    • Le menu
    • L'unité de fabrication
    • Le tarif

    Notre but n'étant pas d'écrire ici l'intégralité du logiciel, nous n'allons pas détailler l'implémentation de tous ces objets jusqu'au niveau final, d'autant que certains sont liés au matériel. Nous allons juste en reprendre quelques-uns, pour montrer des exemples (terminaux et non terminaux) du processus d'itération de la méthode.

    Le produit modifier

    Il est temps maintenant de décider de la représentation effective du Produit. Nous avons déjà vu que ce devait être un type discret. Le premier niveau de maquettage n'a fait apparaître aucun besoin nouveau: il s'agit d'un simple identifiant totalement neutre; nous avons vu qu'à la limite, on pouvait assimiler le produit à un numéro de bouton. Un type énumératif serait parfaitement acceptable, puisque nous n'avons besoin d'aucune opération arithmétique, mais présente cependant un inconvénient: il est plus difficile à faire évoluer, par exemple si l'on crée un nouvel appareil disposant d'un nombre supérieur de boutons. Nous choisirons donc de le représenter par un type entier. Les bornes peuvent être données par une constante du paquetage, ou dans un paquetage global définissant la configuration matérielle, en particulier le nombre de boutons (et donc le nombre de produits pouvant être distribués).

    package Définition_Produit is
    	Max_produit : constant := 10;
    	type Produit is range 1..Max_produit;
    end Définition_Produit;
    

    Le monnayeur modifier

    1. Reprendre les spécifications
    2. Nous sommes maintenant chargé d'étudier le problème du monnayeur. À ce stade, nous oublions tout ce que nous avons pu dire du distributeur en général pour étudier ce qu'est un monnayeur. Il apparaît immédiatement que celui que nous avons défini est trop spécifique. Rendre_Monnaie a comme paramètre un Produit; ceci induit un couplage supplémentaire totalement inutile. De plus, rien ne dit que le monnayeur fasse partie d'un appareil où la notion de produit ait un sens; ce pourrait être par exemple une machine à sous (type «bandit manchot»), auquel cas il peut arriver que l'on soit amené[1] à «rendre» un montant supérieur à celui introduit! Il est beaucoup plus logique que Rendre_Monnaie et Attendre aient un argument de type Argent: le montant à rendre ou à attendre. Le monnayeur n'a plus à savoir pour quelles raisons on attend ou on rend cet argent. Cette modification permet de faire disparaître la procédure Rembourser, qui introduisait d'ailleurs un couplage temporel, le montant à rendre dépendant du montant introduit. D'autres question de sémantique délicate auraient pu se poser, comme celle de savoir si l'on avait le droit de rembourser lorsque le monnayeur n'était pas dans l'état bloqué... Notre spécification devient :
      with Définition_Argent; use Définition_Argent;
      package Monnayeur is
      	procedure Activer;
      	procedure Bloquer;
      	procedure Attendre (Montant : Argent);
      	function  Montant_Introduit return Argent;
      	procedure Rendre_Monnaie (Montant : Argent);
      	Rendu_Impossible : exception;
      end Monnayeur;
      

      Bien sûr, il faut immédiatement prévenir le niveau supérieur que nous souhaitons modifier les spécifications. Le programme principal va devenir :

      with Définition_Produit, Définition_Argent, Menu, 
           Monnayeur,          Unité_Fabrication, Tarif;
      use  Définition_Produit, Définition_Argent, Menu,
           Monnayeur,          Unité_Fabrication, Tarif;
      procedure Principal is
      	Choisi : Produit;
      	Annulé : Boolean;
      begin
      	loop
      		Menu.Activer;
      		Monnayeur.Activer;
      		select
      			Menu.Annulation.Signalement;
      			Annulé := True;
      		then abort
      			Choisi := Choix_consommateur;
      			Attendre (Montant => Le_Prix (De => Choisi));
      			Annulé := False;
      		end select;
      		Monnayeur.Bloquer;
      		if Annulé then
      			Rendre_Monnaie (Montant => Montant_Introduit);
      		else
      			begin
      				Rendre_Monnaie (Montant => 
      				     Montant_Introduit - Le_Prix (De => Choisi));
      				Confectionner (Le_Produit => Choisi);
      			exception
      				when Rendu_Impossible =>
      					Rendre_Monnaie	 (Montant => Montant_Introduit);
      				when Confection_Impossible =>
      					Invalider (Le_Produit => Choisi);
      					Rendre_Monnaie (Montant => Montant_Introduit);
      			end;
      		end if;
      	end loop;
      exception
      	when others =>
      		Monnayeur.Bloquer;
      		Rendre_Monnaie (Montant => Montant_Introduit);
      end Principal;
      

      Un logiciel à caractère temps réel comme celui-ci doit fonctionner dans tous les cas, même ceux qui sont hautement improbables. Nous devons vérifier qu'aucune condition de course ne peut se produire suite à notre modification: en effet l'instruction

      Rendre_Monnaie (Montant_Introduit-Le_Prix (De => Choisi));
      

      n'est pas atomique; si l'utilisateur introduit une pièce entre le moment où la valeur de la monnaie à rendre est calculée et le moment où la procédure est appelée, il y a un risque de rendre un montant incorrect. C'est pourquoi le Montant_Introduit est calculé après avoir bloqué le monnayeur; si l'utilisateur continue d'introduire des pièces, elles retomberont directement dans la sébile sans être encaissées et ne gêneront donc pas le programme. Pensons également qu'une pièce peut être introduite après que nous sommes revenu de la procédure Attendre; ce n'est pas gênant, car elle sera comptabilisée normalement et nous en tiendrons donc compte au niveau de Rendre_Monnaie.

      Une autre condition de course risque de se produire au niveau de l'instruction select: que se passe-t-il si l'utilisateur appuie sur le bouton d'annulation juste après que nous avons mis la variable Annulé à False ? Si nous sommes sorti de l'instruction, nous ne nous en apercevrons même pas: l'utilisateur a pressé le bouton trop tard. Si nous ne sommes pas encore sorti du select, la partie avant then abort sera exécutée, y compris la remise à True de la variable Annulé. La fourniture du produit sera ou bien totalement annulée, ou pas du tout, mais aucun cas intermédiaire ne peut se produire.

      Bien entendu, nous devons immédiatement ajuster le corps maquette de Monnayeur pour refléter ces modifications (on le trouvera en annexe), puis recompiler et vérifier la nouvelle maquette. De cette façon, nous nous assurons qu'à tout moment le programme reste stable et vérifié.

      1. Certes rarement !
    3. Définir le plan d'abstraction
    4. À ce niveau d'abstraction, le monnayeur est chargé de faire la liaison entre la vue de haut niveau dont ont besoin les autres modules et les dispositifs physiques. Les objets que nous trouverons à ce niveau correspondront aux relais, commandes, etc., des dispositifs physiques. Nous ne les verrons que sous forme abstraite: leur implémentation dépendra directement du matériel.
    • Identifier les objets utilisés
    Nous voyons tout de suite que le monnayeur doit comporter (au moins) deux parties principales: une tâche cachée, active en permanence, chargée de comptabiliser les pièces qui tombent et de gérer l'afficheur du montant introduit et un ensemble de services exportés. Les services rendus à l'extérieur nécessitent la présence d'un compteur pour comptabiliser l'argent introduit; nous avons déjà vu que nous devions à ce niveau piloter un clapet gouvernant la chute des pièces vers le compteur ou vers la sébile; enfin les pièces doivent tomber dans des réservoirs de pièces que nous devrons commander pour le rendu de monnaie. Le compteur doit être un objet actif, car il doit «voir passer» des pièces à tout moment. Il se décompose donc en un compteur proprement dit et une tâche de surveillance. Nous avons alors deux possibilités: avoir le compteur à l'intérieur de la tâche (la tâche est en quelque sorte le compteur lui-même), ou voir le compteur comme un objet externe à la tâche, celle-ci n'étant chargée que de la surveillance des pièces et de la mise à jour du compteur. En Ada 83, nous aurions certainement adopté la première solution: en effet, le compteur doit être protégé contre des accès concurrents; on peut interroger son montant à tout moment, y compris quand la tâche est en train de le modifier. Comme le seul moyen de protéger une variable était de la mettre dans une tâche, il était tout indiqué d'utiliser la tâche que nous avons sous la main. Sinon, il aurait fallu rajouter une tâche spéciale; c'était faisable, mais non nécessaire (puisque nous envisagions déjà, pour des raisons structurelles, d'inclure le compteur dans la tâche de toute façon). La multiplication abusive des tâches est un travers dans lequel tombent des programmeurs Ada, enthousiasmés au-delà du raisonnable par ces nouvelles possibilités. Les tâches sont une bonne chose, mais il ne faut pas abuser des bonnes choses! Ada 95 offre de nouvelles possibilités, grâce aux types protégés (ce qui ne signifie pas non plus qu'il faille se ruer dessus). Nous n'avons plus de raison technique d'implémentation pour faire ce choix: c'est la logique qui doit nous guider. En l'occurrence, nous appliquerons le principe suivant lequel chaque module doit faire une chose et une seule; nous préférerons donc séparer le compteur de la tâche de surveillance, au nom du découplage. Noter également que nous limitons ainsi la profondeur d'imbrication des concepts. Enfin, la notion d'entrée de type protégé, avec sa garde conditionnelle, est vraisemblablement un bon moyen d'implémenter Attendre sans boucle active. Nous prenons donc la décision de conception d'utiliser un type protégé, mais nous nous rappelons (et nous notons dans le cahier des décisions de conception) qu'un autre choix était possible à ce stade. Nous disposons donc d'un objet que nous appellerons le compteur, protégé contre des accès simultanés. Les opérations nécessaires sont de lui ajouter une valeur, de connaître la valeur courante, de le remettre à 0 et d'attendre qu'un certain montant soit introduit. Ceci s'exprime naturellement comme :
    protected Compteur is
    	procedure Ajouter (Montant : Argent);
    	function  Valeur_Courante return Argent;
    	procedure Remise_A_Zéro;
    	entry Attendre (Montant : Argent);
    end Compteur;
    

    Les procédures Activer et Bloquer doivent piloter le clapet. Un clapet est (extérieurement) un objet simple: il possède un état ouvert et un état fermé et les opérations pour l'ouvrir ou le fermer. Ceci s'exprime comme:

    package Clapet is
    	procedure Ouvrir;
    	procedure Fermer;
    end Clapet;
    

    Faut-il faire un paquetage (de type machine abstraite), alors que l'on pourrait avoir une simple procédure (avec un paramètre ouvert/fermé)? Oui, car si la spécification est simple, l'implémentation peut être plus subtile. Par exemple, il faut garantir qu'on ne sort de la procédure Ouvrir (ou Fermer) que lorsque le clapet est effectivement ouvert ou fermé. Comme il s'agit d'un dispositif mécanique, ce n'est pas immédiat et il faut introduire un retard ou un contact annexe pour vérifier la position du relais. Cette dernière solution est un peu luxueuse pour nous (après tout, en cas de panne, le risque ne dépasse pas quelques francs), mais est indispensable pour les relais gouvernant des dispositifs de sécurité, comme ceux qui commandent les signaux des trains.

    Pour pouvoir rendre la monnaie, il nous faut à l'évidence savoir combien il nous reste de pièces de chaque sorte et déclencher physiquement la chute d'une ou plusieurs pièces. Le réservoir de pièces doit donc fournir ces fonctionnalités. Comme nous devrons avoir un réservoir associé à chaque sorte de pièce, nous en faisons un type de donnée abstrait :

    package Réservoirs_De_Pièces is
    	Capacité_Max : constant := 100;
    	type Capacité_réservoir is range 0 .. Capacité_Max;
    	type Réservoir is private;
    	function Nombre_De_Pièces (Dans : Réservoir) 
    		return Capacité_Réservoir;
    	procedure Commander_Chute (Depuis : Réservoir);
    	Réservoir_Vide : exception;
    private
    	...
    end Réservoirs_De_Pièces;
    

    Remarquons au passage que nous avons dû définir un type pour la valeur retournée par Nombre_De_Pièces. Il aurait été tentant d'utiliser Integer... Mais il existe un problème bien réel: un magasin de pièces a nécessairement une capacité limitée. Nous préférons refléter cette propriété de l'objet physique dans les types du programme. Enfin, on ne peut exclure qu'une chute de pièce soit commandée alors qu'il ne reste rien dans le réservoir: nous devons donc prévoir une exception Réservoir_Vide pour ce cas.

    Nous devons ensuite voir si l'objet ainsi défini nous permet de résoudre notre problème: rendre la monnaie. L'algorithme le plus simple consiste à laisser tomber des pièces de la plus grande valeur inférieure au montant à rendre, jusqu'à ce que le montant restant soit inférieur à la valeur de la pièce, ou que le réservoir soit épuisé; puis à passer au réservoir de pièces de montant inférieur. Malheureusement, avec cet algorithme, on ne s'aperçoit que le rendu est impossible qu'après avoir commencé à laisser tomber des pièces... Il faut donc travailler en deux temps: d'abord calculer le nombre de pièces de chaque réservoir à rendre, puis, si le rendu est possible, commander les réservoirs. Le problème se complique: il va falloir définir quelque part un ensemble de pièces... Mais au fait, nous parlons de pièces et nous n'avons aucun objet correspondant dans notre analyse! Il est temps de réfléchir à ce que nous allons appeler pièce, pour notre vue et à ce niveau d'abstraction.

    Nous parlons ici des vraies pièces de monnaie, notion qui varie selon le pays, les fluctuations de la politique monétaire... Il serait bon de concentrer dans un seul paquetage tout ce qui dépend de la définition des pièces. Ceci comprend l'éventail des pièces disponibles, leurs valeurs, les réservoirs associés et la façon d'obtenir un montant donné à partir des pièces disponibles. Nous en faisons un paquetage distinct, pour pouvoir le modifier sans perturber le reste de l'application et pour lui garder une bonne autonomie; mais comme il fait logiquement partie du monnayeur et n'a aucune raison d'être accessible de l'extérieur, nous en ferons un enfant privé. Ceci nous donne :

    with Réservoirs_De_Pièces; use Réservoirs_De_Pièces;
    private package Monnayeur.Définition_Pièces is
    	type Pièces is (Cts_50, 
    	                Franc_1, Franc_2, Franc_5, Franc_10); -- à faire : mettre en Euros !?
    	Valeurs : constant array (Pièces) of Argent
    	        := (0.50, 1.00, 2.00, 5.00, 10.00);
    	Magasin : array (Pièces) of Réservoir;
    	type Répartition_Pièces is array (Pièces) 
    		of Capacité_Réservoir;
    	function Répartir (Montant : Argent) 
    		return Répartition_Pièces;
    end Monnayeur.Définition_Pièces;
    

    Notons que la fonction Répartir est essentiellement un algorithme (non évident au demeurant) qu'il sera approprié d'analyser suivant les techniques de programmation structurée.

    • Écrire le corps de l'objet à concevoir

    Nous pouvons maintenant écrire le corps de l'objet monnayeur. Le compteur et la tâche sont des objets séparés, mais inclus dans le monnayeur. Nous séparerons donc leurs corps pour pouvoir compiler (et donc vérifier) l'utilisation que nous en faisons avant de songer à leur implémentation :

    with Clapet, Monnayeur.Définition_Pièces;
    package body Monnayeur is
    	protected Compteur is
    		procedure Ajouter (Montant : Argent);
    		function  Valeur_Courante return Argent;
    		procedure Remise_A_Zéro;
    		entry Attendre (Montant : Argent);
    	end Compteur;
    	protected body Compteur is separate;
    	task Surveillance;
    	task body Surveillance is separate;
    	procedure Activer is
    	begin
    		Compteur.Remise_A_Zéro;
    		Clapet.Ouvrir;
    	end Activer;
    	procedure Bloquer is
    	begin
    		Clapet.Fermer;
    	end Bloquer;
    	procedure Attendre (Montant : Argent) is
    	begin
    		Compteur.Attendre(Montant);
    	end Attendre;
    	function  Montant_Introduit return Argent is
    	begin
    		return Compteur.Valeur_Courante;
    	end Montant_Introduit;
    	procedure Rendre_Monnaie (Montant : Argent) is
    		use Définition_Pièces;
    		Répartition : constant Répartition_Pièces 
    		            := Répartir (Montant);
    	begin
    		for A_Rendre in Pièces loop
    			for Nombre in 1 .. Répartition (A_Rendre) loop
    				Commander_Chute (Magasin (A_Rendre));
    			end loop;
    		end loop;
    	end Rendre_Monnaie;
    end Monnayeur;
    
    • Contester

    La spécification du compteur telle qu'elle est répond à notre besoin; mais est-elle suffisante dans le cas général ? Il reste un point important non spécifié: que se passe-t-il si plusieurs tâches appellent Attendre avant que le montant désiré ait été atteint ? En particulier, que se passe-t-il si plusieurs appels à Attendre ne spécifient pas le même montant attendu ? Bien sûr, on peut dire que cela n'a pas d'importance, vu qu'il n'y aura jamais qu'une seule tâche qui appelle Attendre dans notre système. Cependant, ceci est particulier à notre utilisation. Dans une conception orientée objet, nous devons réfléchir aux propriétés de l'objet indépendamment de toute utilisation particulière. Ce surcroît de travail peut paraître inutile, mais se justifiera dès la première réutilisation et l'expérience montre que ceci arrive souvent un peu plus tard dans le même projet.

    Que faire donc si plusieurs tâches appellent Attendre avec des montants que nous n'avons aucune raison de supposer identiques? Une première possibilité est de maintenir une liste de demandes triée par montant attendu croissant ; nous libérons les clients au fur et à mesure que le compteur atteint les différents seuils. Une autre possibilité est de ne traiter qu'une demande à la fois : si une nouvelle demande arrive, elle est bloquée jusqu'à ce que le compteur soit remis à zéro; une nouvelle demande est alors acceptée et ainsi de suite. Enfin, on peut interdire plusieurs attentes simultanées: une deuxième demande recevra alors une exception.

    La première solution correspond à des tâches qui voudraient être libérées progressivement au cours de la même «session» de comptage, la seconde au cas où chaque tâche dispose d'une session différente. Enfin la dernière est plus restrictive, correspondant à notre seul besoin, mais complètement spécifiée par rapport à notre première formulation. On peut trouver des exemples nécessitant chacune de ces solutions. Or dans notre cas, les trois sont également acceptables! Nous manquons donc d'un critère pour faire notre choix. Dans ce cas, il est bon de jeter un coup d'œil sur les possibilités d'implémentation. S'il est nécessaire de toujours penser «réutilisation», il ne faudrait pas que cela conduise à des solutions inutilement compliquées par rapport au besoin; nous pouvons utiliser la faisabilité comme critère de choix.

    Étudions la réalisation de chacune de ces possibilités. Tout d'abord, il existe une difficulté technique commune aux trois: il faut attendre d'atteindre un montant qui figure en paramètre de l'appel à Attendre. Or la garde d'une entrée ne peut dépendre des paramètres de l'appelant. Autrement dit, pour connaître le montant à attendre, il faut accepter l'appel d'entrée. Ce problème est connu depuis longtemps [Wel83] et a été résolu en Ada 95 par l'instruction requeue. La «base commune» aux trois solutions peut s'écrire ainsi[1] :

    protected Compteur is
    	entry Attendre (Montant : Argent);
    private
    	entry Faire_La_Queue;
    	Accumulateur    : Argent := 0.0;
    	Montant_Attendu : Argent := 0.0;
    end Compteur;
    protected body Compteur is
    	entry Faire_La_Queue 
    		when Accumulateur >= Montant_Attendu is
    	begin
    		null;
    	end Faire_La_Queue;
    	entry Attendre (Montant : Argent) when True is
    	begin
    		Montant_Attendu := Montant;
    		requeue Faire_La_Queue with abort;
    	end Attendre;
    end Compteur;
    
    La clause when True de l'entrée Attendre peut sembler bizarre, mais il est obligatoire de fournir une garde pour une entrée de type protégé. Ne peut-on en faire une procédure ? Non, car seule une entrée a le droit de faire un requeue. La mention with abort de l'instruction requeue autorise une éventuelle annulation de la demande par avortement ou fin d'appel temporisé.
    1. Dans cette partie de la discussion, nous ne montrons pour simplifier que la partie du type protégé Compteur liée à l'entrée Attendre. Il faut bien entendu fournir aussi les autres fonctionnalités.

    Au fait, que se passe-t-il ici en cas d'appels multiples ? Le montant attendu est celui du dernier qui appelle ! Si le deuxième appel correspond à un montant inférieur au premier, la première demande sera débloquée alors que le montant attendu n'a pas été atteint. Un tel comportement est inacceptable.

    La solution la plus simple consiste à lever une exception. L'entrée Attendre ne peut être appelée qu'une seule fois après une remise à zéro. Mais que faire si quelqu'un se trouve en attente lors de la remise à zéro ? Comme nous n'autorisons qu'une seule tâche à utiliser le compteur, le mieux est de lever également une exception. Laquelle ? Puisqu'il s'agit d'un comportement qui résulte d'une violation des règles d'utilisation de l'objet par le programmeur, le mieux est de lever Program_Error. Notre compteur devient :

    protected Compteur is
    	entry Attendre (Montant : Argent);
    	entry Remise_A_Zéro;
    private
    	entry Faire_La_Queue;
    	Accumulateur    : Argent  := 0.0;
    	Montant_Attendu : Argent  := 0.0;
    	Déjà_Utilisé    : Boolean := False;
    end Compteur;
    protected body Compteur is
    	entry Faire_La_Queue 
    		when Accumulateur >= Montant_Attendu is
    	begin
    		null;
    	end Faire_La_Queue;
    	entry Attendre (Montant : Argent) when True is
    	begin
    		if Déjà_Utilisé then
    			raise Program_Error;
    		end if;
    		Déjà_Utilisé    := True;
    		Montant_Attendu := Montant;
    		requeue Faire_La_Queue with abort;
    	end Attendre;
    	procedure Remise_A_Zéro is
    	begin
    		if Faire_La_Queue'Count > 0 then
    			raise Program_Error;
    		end if;
    		Déjà_Utilisé := False;
    		Accumulateur := 0.0;
    	end Remise_A_Zéro;
    end Compteur;
    

    Une variante consiste à interdire l'appel d’attendre si quelqu'un est déjà en attente, mais à autoriser plusieurs attentes même s'il n'y a pas de remise à zéro intermédiaire :

    protected Compteur is
    	entry Attendre (Montant : Argent);
    private
    	entry Faire_La_Queue;
    	Accumulateur    : Argent := 0.0;
    	Montant_Attendu : Argent := 0.0;
    end Compteur;
    protected body Compteur is
    	entry Faire_La_Queue 
    		when Accumulateur >= Montant_Attendu is
    	begin
    		null;
    	end Faire_La_Queue;
    	entry Attendre (Montant : Argent) when True is
    	begin
    		if Faire_La_Queue'Count > 0 then
    			raise Program_Error;
    		end if;
    		Montant_Attendu := Montant;
    		requeue Faire_La_Queue with abort;
    	end Attendre;
    end Compteur;
    

    Nous n'avons pas examiné les solutions permettant de libérer plusieurs tâches au fur et à mesure de l'incrément du compteur ; elles sont nettement plus compliquées, aussi proposons-nous de nous en tenir à l'une de celles que nous avons étudiées. La plus simple est finalement la dernière : pas d'exception à traiter, expression naturelle du comportement par les gardes du type protégé. Nous la choisirons donc, mais nous retiendrons que d'autres étaient acceptables.

    Un œil critique remarquera aisément que le compteur protégé que nous venons de définir semble très utile ; tellement utile même qu'il est vraisemblable que de nombreux projets pourraient avoir besoin de fonctionnalités semblables. Ne pourrait-on en faire un composant réutilisable ? Demandons-nous donc ce qu'est un compteur. C'est quelque chose qui contient une valeur, qui nous permet de l'augmenter et de connaître sa valeur courante. Nous pourrions nous limiter à compter des nombres entiers, mais a priori rien n'empêche de compter d'autres choses ; la seule nécessité est que la notion d'addition ait un sens pour les valeurs à compter, ainsi bien entendu qu'une valeur nulle pour la remise à zéro. Enfin, comme nous voulons pouvoir attendre d'atteindre une valeur, il est nécessaire de disposer d'une fonction de comparaison. Ceci s'exprime comme suit :

    generic
    	type A_Compter is private;
    	Valeur_Nulle : in A_Compter;
    	with function "+" (Left, Right: A_Compter) 
    		return A_Compter is <>;
    	with function "<" (Left, Right: A_Compter) 
    		return Boolean is <>;
    package Compteur_Protégé_Générique is
    	procedure Attendre (Valeur : A_Compter);
    	procedure Incrémenter (De : A_Compter);
    	function  Valeur_Courante return A_Compter;
    	procedure Remise_A_Zéro;
    end Compteur_Protégé_Générique;
    

    On trouvera le corps du compteur générique en annexe. Le corps final et définitif (ce n'est plus une maquette) du monnayeur devient :

    with Compteur_Protégé_Générique,  Réservoirs_De_Pièces,
         Monnayeur.Définition_Pièces, Clapet;   
    package body Monnayeur is
    	package Le_Compteur is 
    		new Compteur_Protégé_Générique (
    			A_Compter    => Définition_Argent.Argent,
    			Valeur_Nulle => 0.0);
    	task Surveillance;
    	task body Surveillance is separate;
    	procedure Activer is
    	begin
    		Le_Compteur.Remise_A_Zéro;
    		Clapet.Ouvrir;
    	end Activer;
    	procedure Bloquer is
    	begin
    		Clapet.Fermer;
    	end Bloquer;
    	procedure Attendre (Montant : Argent) is
    	begin
    		Le_Compteur.Attendre(Montant);
    	end Attendre;
    	function  Montant_Introduit return Argent is
    	begin
    		return Le_Compteur.Valeur_Courante;
    	end Montant_Introduit;
    	procedure Rendre_Monnaie (Montant : Argent) is
    		use Monnayeur.Définition_Pièces, Réservoirs_De_Pièces;
    		Répartition : constant Répartition_Pièces 
    		            := Répartir (Montant);
    	begin
    		for A_Rendre in Pièces loop
    			for Nombre in 1 .. Répartition (A_Rendre) loop
    				Commander_Chute (Magasin (A_Rendre));
    			end loop;
    		end loop;
    	end Rendre_Monnaie;
    end Monnayeur;
    
  • Tester le plan d'abstraction
  • Pour pouvoir tester le plan d'abstraction, il nous manque le corps de la tâche Surveillance (qui fait logiquement partie de ce plan). Celle-ci est intimement liée au matériel, aussi allons-nous nous contenter de fournir un corps maquette. Les corps maquettes des modules Clapet et Définition_Pièces (qui n'ont rien de particulier) sont fournis en annexe.
    with Ada.Text_IO; use Ada.Text_IO;
    separate (Monnayeur) task body Surveillance is
    	C : Character;
    begin
    	loop
    		Put ("Pièce entrée :");
    		Get_Immediate (C);
    		case C is
    		when 'C' => Le_Compteur.Incrémenter (De =>  0.50);
    		when '1' => Le_Compteur.Incrémenter (De =>  1.00);
    		when '2' => Le_Compteur.Incrémenter (De =>  2.00);
    		when '5' => Le_Compteur.Incrémenter (De =>  5.00);
    		when 'D' => Le_Compteur.Incrémenter (De => 10.00);
    		when others =>          
    				null;    -- Ignorer les autres caractères
    		end case;
    		Put ("Montant total : "); 
    		Put (Argent'Image (Montant_Introduit));
    		New_Line;
    	end loop;
    end Surveillance;
    
    La procédure Get_Immediate, de Text_IO, permet de lire un caractère au clavier sans attendre de retour chariot.
  • Documenter le plan d'abstraction
  •  
    Figure 41: Graphe du monnayeur
    Figure 41: Graphe du monnayeur

    Résumons ici les points importants de la structure que nous avons adoptée. Le monnayeur est constitué d'un compteur protégé et d'une tâche chargée de gérer la chute des pièces. Il gère des réservoirs de monnaie contenant les pièces, et le clapet qui autorise ou interdit la chute des pièces. Le graphe (partiel) concernant la vue du monnayeur est indiqué en figure 41, où le rectangle grisé exprime que les éléments qui le constituent forment un sous-système.

    L'unité de fabrication modifier

    1. Reprendre les spécifications
    2. L'interface définie précédemment est simple et correspond bien à un objet du monde réel (il suffit d'avoir vu un distributeur ouvert pour s'en convaincre). Il ne semble pas nécessaire d'y apporter de modifications.
    3. Définir le plan d'abstraction
    4. L'unité de fabrication est chargée de confectionner les produits. Cette «confection» est relativement simple : il suffit de piloter à tour de rôle un certain nombre de distributeurs d'ingrédients. Il faut noter quelque part la liste des ingrédients pour un produit : appelons cela une recette. Le paquet de cacahuètes n'est alors qu'un cas simple de recette, ne comportant qu'un seul ingrédient : le paquet lui-même. En résumé, le distributeur doit piloter des distributeurs d'ingrédients en fonction de la recette du produit. Une recette est une liste d'ingrédients entrant dans la fabrication du produit. Pour établir la relation entre un produit et sa recette, il faut un livre de recettes. Un distributeur d'ingrédients est chargé de servir quelque chose: une cuillère, un gobelet, une certaine quantité de sucre ou de liquide. Les distributeurs d'ingrédients peuvent être ouverts ou fermés. Le problème est de déterminer en fonction de quel critère on décidera de les refermer. Par exemple, les distributeurs de petites cuillères ont un fonctionnement élémentaire: distribuer un élément. La fermeture d'un distributeur de liquide peut être commandée au bout d'un certain temps, ou lorsqu'un débitmètre aura mesuré une certaine quantité de liquide. De même, la distribution du sucre peut être commandée de façon binaire (sucre en doses), après un temps d'écoulement, ou selon une balance... Le choix entre ces solutions va dépendre des caractéristiques du matériel sous-jacent, mais il serait dangereux de se lier à des caractéristiques de si bas niveau pour le moment. Aussi, nous allons différer la décision en n'introduisant pour l'instant que des ingrédients de haut niveau. On considérera ainsi que «un peu d'eau» ou «beaucoup d'eau» (pour un café court ou long) sont des ingrédients différents. Ils pourront bien entendu commander le même dispositif physique. Si extérieurement nous voulons faire apparaître tous les ingrédients de façon uniforme, chaque implémentation sera différente. Il est certain que la notion de distributeur d'ingrédients est polymorphe. Nous avons un choix de conception à ce niveau entre types à discriminant et type étiqueté. Un type à discriminant serait certainement acceptable[1]; toutefois, cela nécessiterait que chacune des opérations sur les distributeurs d'ingrédients comporte des instructions case selon le type de produit distribué; nous n'aurions pas centralisé tout ce qui concerne un ingrédient particulier. Cet argument paraît faible ici, puisqu'il n'y a qu'une seule opération sur les distributeurs d'ingrédient; mais nous raisonnons dans le cas général et rien ne permet de penser qu'une évolution ultérieure du logiciel ne nécessitera pas de rajouter des opérations. De plus, les différentes sortes d'ingrédients sont indépendantes: il n'y a aucune raison que la distribution des petites cuillères «connaisse» la distribution d'eau. Il paraît plus judicieux de séparer les implémentations de chaque ingrédient dans des procédures distinctes; nous ferons donc d'Ingrédient un type étiqueté abstrait (il n'est pas possible d'avoir un ingrédient qui ne serait pas en même temps quelque chose de plus précis) et chaque ingrédient sera dérivé de la classe générale. Le type général n'a aucune propriété particulière, il ne sert que de racine. Cependant, il doit avoir une sémantique de référence (on ne peut «copier» un dispositif physique). Pour renforcer cette sémantique, la procédure Servir utilisera un «paramètre accès» : nous exprimons bien que nous ne pouvons manipuler que des références à des distributeurs.
      Les paramètres accès constituent un nouveau mode (en plus de in, out et in out), rajouté par Ada 95, sous la forme access <type>. Ils correspondent à n'importe quelle valeur accès dont le type désigné est celui indiqué. Les sous-programmes utilisant ces paramètres sont des sous-programmes primitifs du type désigné: ils peuvent donc être utilisés pour la liaison dynamique.
      1. C'est ce que nous aurions utilisé en Ada 83.

      Enfin, il faudra accéder par des moyens matériels au dispositif physique; il semble donc approprié de paramétrer le type au moyen d'un discriminant caractéristique de l'appareil. Quel doit être le type de ce discriminant ? Nous supposons que nous accédons aux appareils par des interfaces sous forme de mappings d'adresses; le type Address semble approprié. Mais d'une part, la définition de Address dépend de l'implémentation et d'autre part il se peut que l'adresse d'un appareil ne corresponde pas à une adresse mémoire, auquel cas un type entier serait plus «ouvert» pour fournir la notion générale d'«adresse de périphérique». Le type Integer_Address procure un compromis acceptable entre ces deux contraintes.

      Le type Integer_Address du paquetage System.Storage_Elements est est une «vue» sous forme de nombre entier de la notion d'adresse. Le paquetage fournit des conversions entre ce type et le «vrai» type Address. Ceci permet d'effectuer des contrôles lors des conversions et d'éviter les problèmes connus lors de l'utilisation indisciplinée de nombres entiers comme adresses.

      Le paquetage définissant la classe des distributeurs ne peut (et ne doit) être utilisé que par l'unité de fabrication ; il n'y a aucune raison de le laisser accessible de l'extérieur. Nous en ferons donc un enfant privé. Nous pouvons exprimer ces décisions de conception comme :

      with System.Storage_Elements; use System.Storage_Elements;
      private package Unité_Fabrication.Distributeur is
      	type Instance (Adresse : Integer_Address) is
      		abstract tagged limited private;
      	subtype Classe is Instance'Class;
      	procedure Servir_Dose(De : access Instance) is abstract;
      private
      	type Instance (Adresse : Integer_Address) is
      		abstract tagged limited null record;
      end Unité_Fabrication.Distributeur;
      

      Nous ne pouvons exclure le cas où l'on demanderait au distributeur de confectionner un Produit épuisé, ou simplement celui où un ingrédient viendrait à manquer en cours de fabrication. La procédure Servir_Dose de chaque implémentation doit lever l'exception Confection_Impossible si elle échoue pour quelque raison que ce soit.

      À partir de ce type de base, on dérive différents types de distributeurs d'ingrédients concrets, correspondant aux différents modèles d'appareils physiques : distributeur de liquide, de poudre, distributeur élémentaire (petite cuillère ou paquet de cacahuète). Certains produits peuvent avoir besoin de paramètres : quantité fournie, temps de réaction d'un relais, que l'on fournira grâce à des discriminants supplémentaires lors de la dérivation; encore faut-il définir quelque part les types correspondants. Où ? Ces types sont des types globaux pour le sous-système de l'unité de fabrication; le plus simple est de les mettre dans la spécification de Unité_Fabrication, ce qui les rendra visibles pour toutes les unités enfants. Mais comme il n'y a pas de raison de les rendre visible de l'extérieur du sous-système, il faut les mettre dans la partie privée de Unité_Fabrication. Comme il n'y a pas de modification de la partie visible du paquetage, nous savons que ceci n'aura aucune conséquence sur les couches de plus haut niveau. Le paquetage devient donc :

      with Définition_Produit; use Définition_Produit;
      package Unité_Fabrication is
      	procedure Confectionner (Le_produit : Produit);
      	Confection_Impossible : exception;
      private
      	type Temps_Ouvert is range 0 .. 10_000; -- en ms.
      	ms : constant Temps_Ouvert := 1;
      	type Volume_Livré is range 0 .. 50; -- en cl.
      	cl : constant Volume_Livré := 1;
      end Unité_Fabrication;
      
      Remarquer la définition de la constante ms: elle auto-documente que l'unité de mesure est la milliseconde et permet d'écrire une valeur sous la forme (plus lisible) 3*ms. En cas d'évolution du logiciel, si par exemple on décide d'utiliser le dix-millième de seconde comme unité du type, il suffira de changer cette valeur pour ne pas perturber les utilisateurs. La même remarque s'applique à cl.

      Pour le distributeur de boissons, nous avons besoin de trois sortes de distributeurs d'ingrédients : le modèle simple qui laisse tomber un objet (petite cuillère), paramétré en fonction du temps pour s'assurer que l'objet est bien tombé, le distributeur de poudre (sucre, cacao) et le distributeur de liquide, paramétré par la quantité à distribuer. Ces paquetages sont des enfants privés du distributeur. Ceci nous donne :

      with Unité_Fabrication.Distributeur;
      with System.Storage_Elements; use System.Storage_Elements;
      private package Unité_Fabrication.Distributeur_Simple is
      	type Instance (Adresse   : Integer_Address;
      	               Ouverture : Temps_Ouvert)
      		is new Distributeur.Instance (Adresse) with private;
      	procedure Servir_Dose (De : access Instance);
      private
      	type Instance (Adresse   : Integer_Address;
      	               Ouverture : Temps_Ouvert) is 
      		new Distributeur.Instance (Adresse) with null record;
      end Unité_Fabrication.Distributeur_Simple;
      with Unité_Fabrication.Distributeur;
      with System.Storage_Elements; use System.Storage_Elements;
      private package Unité_Fabrication.Distributeur_Poudre is
      	type Instance (Adresse   : Integer_Address)is 
      		new Distributeur.Instance (Adresse) with private;
      	procedure Servir_Dose (De : access Instance);
      private
      	type Instance (Adresse   : Integer_Address)is 
      		new Distributeur.Instance (Adresse) with null record;
      end Unité_Fabrication.Distributeur_Poudre;
      with Unité_Fabrication.Distributeur;
      with System.Storage_Elements; use System.Storage_Elements;
      private package Unité_Fabrication.Distributeur_Liquide is
      	type Instance (Adresse : Integer_Address;
      	               Volume  : Volume_Livré)
      		is new Distributeur.Instance (Adresse) with private;
      	procedure Servir_Dose (De : access Instance);
      private
      	type Instance (Adresse : Integer_Address;
      	               Volume  : Volume_Livré) is
      		new Distributeur.Instance (Adresse) with null record;
      end Unité_Fabrication.Distributeur_Liquide;
      

      Nous n'avons défini pour l'instant que des types de distributeurs ; il nous faut maintenant définir des instances. On peut comparer ceci au montage de l'appareil réel: nous allons chercher dans les stocks des distributeurs d'ingrédients afin de les monter dans l'appareil. L'ensemble des distributeurs montés constitue la configuration de l'appareil. Nous pouvons décrire ceci comme :

      with Unité_Fabrication.Distributeur_Simple;
      with Unité_Fabrication.Distributeur_Poudre;
      with Unité_Fabrication.Distributeur_Liquide;
      private package Unité_Fabrication.Configuration is
      	Cuillère  : aliased Distributeur_Simple.Instance
                      (Adresse   => 16#1F4#, Ouverture => 50*ms);
      	Cacao     : aliased Distributeur_Poudre.Instance
      	              (Adresse => 16#1F6#);
      	Café      : aliased Distributeur_Poudre.Instance
      	              (Adresse => 16#1F8#);
      	Sucre     : aliased Distributeur_Poudre.Instance
      	              (Adresse => 16#1FA#);
      	Eau_courte: aliased Distributeur_Liquide.Instance
      	              (Adresse => 16#1FC#, Volume  => 10*cl);
      	Eau_longue: aliased Distributeur_Liquide.Instance
      	              (Adresse => 16#1FE#, Volume  => 25*cl);
      end Unité_Fabrication.Configuration;
      

      Une recette est une liste d'ingrédients associée à un produit. La recette d'un produit est fournie par un livre de recettes. En bonne logique, il faut une opération pour ajouter une recette au livre; en pratique, on peut supposer que les recettes sont construites dans le corps du paquetage. On pourra ajouter cette fonctionnalité par la suite, par exemple pour permettre de changer les recettes «sur place» au moyen d'un terminal portable. Ce paquetage est une machine abstraite qui n'est utilisée que par l'unité de fabrication; nous en faisons donc un paquetage enfant.

      with Unité_Fabrication.Distributeur, Définition_Produit;
      use  Unité_Fabrication.Distributeur, Définition_Produit;
      package Unité_Fabrication.Livre_de_recettes is
      	type Index_Recette is range 0..10;
      	type Elément_Recette is
      		access constant Unité_Fabrication.Distributeur.Classe;
      	type Recette is	
      		array (Index_Recette range <>) of Elément_Recette;
      	function La_Recette (De : Produit) return Recette;
      end Unité_Fabrication.Livre_de_recettes;
      

      Nous pouvons maintenant écrire le corps de Unité_Fabrication :

      with Unité_Fabrication.Distributeur,
           Unité_Fabrication.Livre_de_recettes;
      package body Unité_Fabrication is
      	procedure Confectionner (Le_Produit : Produit) is
      		use Distributeur, Livre_de_recettes;
      		R: constant Recette := La_Recette (De => Le_Produit);
      	begin
      		for I in R'Range loop
      			Servir_Dose (R (I));	-- Liaison dynamique
      		end loop;
      	end Confectionner;
      end Unité_Fabrication;
      

      Nous avons pu supprimer les dépendances à ADPT: le corps de ce paquetage est définitif.

    5. Tester le plan d'abstraction
    6. Il manque encore l'implémentation des différents distributeurs d'ingrédients, ainsi que du livre de recettes. On peut facilement fournir des corps maquettes pour les ingrédients, comme :
      with ADPT;
      package body Unité_Fabrication.Distributeur_Simple is
      	procedure Servir_Dose (De : access Instance) is
      		use ADPT;
      	begin
      		ADPT_Action
      			("Je distribue cuillère : " &
      		   Integer_Address'Image (De.Adresse) & " pendant " &
      		   Temps_Ouvert'Image (De.Ouverture)  & " ms");
      	end Servir_Dose;
      end Unité_Fabrication.Distributeur_Simple;
      

      On trouvera en annexe les autres corps maquettes correspondant aux différentes formes de distributeurs. En ce qui concerne le livre de recettes, on peut lui fournir un premier corps comme :

      with Unité_Fabrication.Configuration;
      use  Unité_Fabrication.Configuration;
      package body Unité_Fabrication.Livre_de_recettes is
      	function La_Recette (De : Produit) return RECETTE is
      	begin
      		case De is
      		when 1 =>
      			return (Cuillère'Access, Café'Access,
      			        Sucre'Access,    Eau_Longue'Access);
      		when 2 =>
      			return (Cuillère'Access, Café'Access,
      			        Sucre'Access,    Eau_Courte'Access);
      		when others =>
      			return (Cuillère'Access, Cacao'Access,
      			        Eau_Longue'Access);
      		end case;
      	end La_Recette;
      end Unité_Fabrication.Livre_de_recettes;
      

      Bien sûr, à terme il faudra remplacer ce corps en utilisant une table, mais celui-ci est suffisant pour nous permettre de tester l'ensemble du logiciel, y compris dans ses aspects temporels.

    7. Documenter le plan d'abstraction
    8. Le rôle de l'unité de fabrication est essentiellement de séparer les points de vue. Chaque distributeur d'ingrédients réalise la distribution d'un ingrédient précis, l'unité de fabrication réalise l'assemblage des composants sans avoir à connaître les éléments qu'elle met en œuvre, grâce au mécanisme de la liaison dynamique. La configuration décrit les éléments matériels effectivement présents dans l'appareil.
       
      Figure 42: Dépendances de l'unité de fabrication
      Figure 42: Dépendances de l'unité de fabrication

      On notera sur le schéma de la figure 42 la présence d'un bus local au sous-système : le Distributeur. C'est la notion abstraite de distributeur d'ingrédient, utilisée par tous les autres modules, qui leur permet de communiquer. En dehors du bus, la Fabrication ne dépend que du Livre_De_Recettes et seul le corps de celui-ci dépend de la Configuration. Ceci montre clairement qu'un changement de configuration n'aurait que des conséquences très limitées sur le logiciel. La Configuration dépend des différents Distributeurs (concrets) qui la composent, et uniquement de ceux-ci: on retrouve qu'elle correspond à un «montage» concret.

    Récapitulation et analyse modifier

    Les objets que nous avons identifiés sont extrêmement généraux et cette structure peut servir à réaliser n'importe quel automate simple. Le monnayeur est directement réutilisable pour tout appareil devant gérer de l'argent (parcmètre, machine à sous...). L'unité de fabrication n'est liée à aucune particularité des boissons: c'est seulement au niveau des distributeurs d'ingrédients qu'apparaît une spécialisation.

    Mais le point le plus important est l'évolutivité : comment réagirait le programme en cas de changements dans les distributeurs physiques, pourrait-on le réutiliser pour d'autres appareils? Un changement de commande des appareils électromécaniques n'impliquerait que des changements dans le corps de l'unité qui les modélise. L'ajout d'un nouvel appareil nécessiterait l'écriture d'une nouvelle classe dérivée; on pourrait alors l'introduire dans la configuration et l'utiliser dans des recettes sans autre modification. De même, le monnayeur pourrait être remplacé par un appareil plus perfectionné : lecteur de carte de crédit par exemple. Cela ne changerait rien au reste du programme. Pour changer le prix des boissons ou la composition des produits, il suffit de changer une table dans le système, certainement une PROM amovible, permettant de remettre à jour le distributeur sans changer le programme.

    Remarquons que cet exemple est indiscutablement orienté objet: tous les éléments manipulés par le programme sont des abstractions correspondant directement aux modules matériels de l'appareil. Cependant, l'héritage ne joue pas un rôle prépondérant: nous l'avons utilisé là où il était utile, mais, n'ayant pas raisonné par classification mais par composition, il n'apparaît pas dans l'organisation générale du projet.

    Nous terminerons par une remarque plus philosophique. Le lecteur a peut-être eu par endroits le sentiment que certains états d'âme étaient abusifs; qu'il existait une solution «bestiale» qu'il suffisait d'appliquer sans se poser tant de questions. Comme nous avons utilisé Ada comme langage d'implémentation, il pourrait en tirer la conclusion qu'Ada « complique les choses ». En fait le problème n'est pas là. Spécifier complètement les comportements même dans les cas limites, étudier systématiquement plusieurs possibilités même lorsqu'une solution paraît évidente, chercher à définir des entités réutilisables doivent (devraient ?) être un souci permanent du développeur quel que soit le langage utilisé. Il se trouve simplement que le pouvoir d'expression d'Ada permet d'exprimer et de maquetter ces différentes solutions, là où d'autres langages n'offrent qu'une seule possibilité. Le programmeur Ada aura donc tendance à se poser plus de questions; mais dans un contexte de génie logiciel, ceci doit être vu comme un avantage important du langage.

    Exercices modifier

    1. Reprendre l'exemple avec une méthodologie par classification. Comparer l'organisation des solutions obtenues.
    2. Écrire le logiciel d'un changeur de monnaie, capable de convertir des francs en dollars. On cherchera à réutiliser au maximum les modules du distributeur.
    3. Dessiner le graphe complet des dépendances du distributeur, et comparer le nombre de dépendances au nombre de modules. Combien vaut K en moyenne ?