Méthodes de génie logiciel avec Ada/Préambule

Le langage que nous utilisons dans ce livre est l'Ada d'aujourd'hui, Ada 95. Celui-ci étant relativement récent, nous avons pensé qu'il serait utile de fournir aux anciens utilisateurs des précisions sur les nouveautés apportées par la révision de la norme.

Une présentation rapide du langage Ada

Aussi avons-nous divisé cette présentation en deux parties. La première explique simplement le «noyau de base», minimum nécessaire pour comprendre le reste de ce livre; presque tous ses éléments figuraient déjà dans Ada 83, et les utilisateurs actuels d'Ada pourront la survoler rapidement. La deuxième partie est plus technique et présente avec plus de détails les grandes lignes des nouveautés importantes. Sa lecture n'est pas nécessaire à la compréhension du reste de l'ouvrage, et ceux qui ne connaissent pas déjà le langage pourront la sauter.

Ce préambule ne cherche pas l'exhaustivité; pour une étude complète du langage, nous renvoyons le lecteur à la bibliographie en fin d'ouvrage.

Origine d'Ada

modifier

Historique

modifier

En janvier 1975 le ministère américain de la Défense (DoD) a constitué un comité d'experts, le High Order Language Working Group (HOLWG), avec pour mission de trouver une approche systématique aux problèmes de qualité et de coûts des logiciels militaires. La plus grosse part de ces logiciels, ou tout du moins là où résidaient les plus gros frais de maintenance, était dans le domaine des systèmes temps réel embarqués (embedded), c'est-à-dire des systèmes informatiques intégrés dans un ensemble électromécanique plus vaste : systèmes d'armes, de radar, de commutation...

Développer un langage de programmation universel apparut alors comme une solution à beaucoup de ces problèmes, et le HOLWG produisit une succession de cahiers des charges spécifiant les caractéristiques souhaitables d'un tel langage. Au printemps de 1977 dix-sept organismes répondirent à l'appel d'offres, parmi lesquels quatre furent retenus pour une pré-étude : Softech, Intermetrics, SRI et Cii-Honeywell-Bull. Les propositions furent évaluées de façon anonyme, et en mars 1978, il ne restait plus en lice qu'Intermetrics et Honeywell-Bull, l'équipe française dirigée par Jean Ichbiah remportant l'appel d'offre un an plus tard. Le langage fut baptisé Ada, du nom d'Ada Augusta Byron.

 
Ada Augusta Byron, comtesse de Lovelace (1815-1851), était la fille du Lord Byron, le fameux poète anglais. Elle était passionnée de mathématiques, chose scandaleuse pour une femme à son époque et qui lui attira bien des inimitiés. Amie et disciple de Charles Babbage, elle eut l'intuition de la possibilité d'utiliser des suites d'instructions codées d'après le modèle des métiers Jacquard. Elle écrivit des programmes pour calculer les nombres de Bernouilli sur la machine de Babbage, mais elle ne put les exécuter puisque la machine ne fut jamais construite. Ses programmes furent recodés bien plus tard en PL/1 et fonctionnèrent, paraît-il, du premier coup. Elle est donc considérée comme le premier programmeur de l'histoire. (cf. Wikipédia)

Le document proposé en 1979 était en fait beaucoup trop vague pour permettre l'établissement d'un standard rigoureux. Une première version révisée fut produite en juillet 1980, et l'on pensait à l'époque que le langage pourrait être normalisé avant la fin de cette même année. Il faut comprendre que la qualité de ce document était au moins aussi bonne que celle de la plupart des normes des autres langages de programmation. Mais pour Ada, il fallait faire beaucoup mieux ! En effet, le document comportait encore nombre de petites imprécisions, de possibilités d'interprétations divergentes ou de dépendances excessives à l'implémentation. L'exigence de portabilité nécessitait une définition beaucoup plus précise, et il fallut attendre 1983 pour obtenir la standardisation par l'ANSI[1]. La standardisation internationale exigeait que la norme fût déposée simultanément en deux des trois langues officielles de l'ISO[2] qui sont l'anglais, le français et le russe. Pour des raisons qui leur appartiennent, les américains préférèrent financer la traduction française plutôt que russe, et la normalisation internationale fut suspendue à la parution de la norme française. La traduction fut effectuée avec un soin extrême : il fallut attendre 1987 pour obtenir une norme satisfaisante [Afn87] et la standardisation ISO [Iso87]. Ada est également une norme européenne (C.E.N. 28652), allemande (DIN 66268), suédoise (SS 63-6115), tchèque, japonaise...

Il était important de définir rigoureusement le langage ; encore fallait-il que les compilateurs respectent la norme ! Pour cela, le DoD a déposé le nom «Ada», et l'autorisation d'utiliser ce nom pour un compilateur fut subordonnée à la confrontation avec succès du compilateur à une suite de validations. Le premier compilateur Ada ainsi validé fut Ada/ED, réalisé par l'équipe de New York University. De nombreux compilateurs ont été validés depuis[3]. Quelques années plus tard, le DoD a abandonné ses droits sur la marque, considérant que cette contrainte n'était plus nécessaire. Il a en revanche déposé une marque de certification, utilisable uniquement par les compilateurs validés. La notion de validation est maintenant tellement liée au langage Ada qu'il n'existe pas de compilateur non validé.

Il est de règle, cinq ans après la parution d'une norme, soit de confirmer le standard tel quel, soit de décider de mettre en route une révision. On savait dès le début que celle-ci serait nécessaire : certains pays n'avaient voté la norme d'origine que pour ne pas ralentir le processus de standardisation et à la condition qu'à la première occasion, on réglerait certains problèmes restés en suspens, principalement celui du jeu de caractères (Ada 83 utilisait l'ASCII 7 bits, qui est notoirement insuffisant pour les langues européennes[4]). Plusieurs autres facteurs concouraient à la nécessité d'une révision. D'abord, comme toute œuvre humaine, le standard comportait des imprécisions, des omissions et des ambiguïtés. À l'ISO, un comité de maintenance, l'ARG (Ada Rapporteur Group),est chargé de régler ces problèmes ; une révision de la norme se devait d'incorporer ses décisions. Ensuite, la mise en œuvre pratique du langage a montré certaines petites faiblesses, ou la nécessité de certains mécanismes dus à l'évolution des modes de programmation (programmation orientée objet notamment). Enfin, il y avait un désir de reformuler certaines règles afin de les rendre plus faciles à interpréter.

La décision fut donc prise en 1988 de démarrer le processus de révision. Le DoD fournit encore une fois le support financier nécessaire, et la révision fut conduite par l'Ada 9X Project Office, dirigé par Chris Anderson, qui confia la réalisation technique à une équipe d'Intermetrics conduite par Tucker Taft. Dans la grande tradition d'ouverture d'Ada, un appel fut lancé aux utilisateurs pour connaître leurs desiderata. La réponse fut très importante, et une synthèse en fut tirée, regroupant par thèmes les principales tendances des demandes, et leur attribuant un degré de priorité. De cette synthèse on tira un document, le Mapping Document, destiné à décrire les transformations nécessaires pour satisfaire les points identifiés à l'étape précédente. Ce document, encore très touffu,comportait quasiment tout ce qu'il était envisageable de modifier, c'est-à-dire nettement plus qu'il n'était souhaitable. Sur la base de ce document, une première réunion de l'ISO se tint au printemps 1992 dans la région de Francfort. Cette réunion marathon (50 participants de 12 pays[5] travaillèrent de façon quasi ininterrompue, pendant une semaine, de 9h du matin à 11h du soir) permit d'aboutir à un accord unanime sur les éléments qu'il convenait de changer dans le langage. D'autres réunions internationales, à Salem puis à Boston (USA), permirent de préciser les détails de la révision. Une dernière réunion au mois de mars 1994 à Villars (Suisse) figea définitivement le langage. Les documents ne subirent alors plus que des changements éditoriaux pendant que le processus bureaucratique suivait son chemin, pour aboutir à l'enregistrement officiel par l'ISO le 15 février 1995.

Mais le temps continua à passer... 1995 plus cinq ans, vint le temps de décider de l'opportunité d'une nouvelle révision. En fait, dès novembre 1999 l'ISO prit la décision de ne pas commencer d'effort de révision. Bien entendu, la norme 1995 comportait des imprécisions et des erreurs qu'il fallait tout de même corriger. Il fut donc décidé de simplement faire paraître des corrigendums techniques pour tenir la définition du langage à jour. Un tel corrigendum technique a été officiellement approuvé le 1er juin 2001.

Un corrigendum technique ne peut que corriger des erreurs de définition du langage, il ne peut rien modifier fondamentalement, ni apporter d'améliorations. L'apparition de Java (avec la notion d'interfaces), certains problèmes pratiques apparus à l'usage, le besoin de nouvelles bibliothèques (conteneurs) demandaient des ajouts plus conséquents que ceux autorisés par le corrigendum. Il fut donc décidé début 2001 de préparer un amendement à la norme. Un amendement permet de rajouter de nouvelles fonctionnalités au langage, mais reste d'ampleur très inférieure à une révision. Le texte de l'amendement a été figé à la fin de 2005, ce qui fait que le langage est appelé Ada 2005, même si la parution officielle par l'ISO n'a été proclamée que le 9 mars 2007.

  1. American National Standard Institute
  2. Organisation Internationale de Normalisation / International Organisation for Standardization / МЕЖДУНАРОДНАЯ ОРГАНИЗАЦИЯ ПО СТАНДАРТИЗАЦИИ. Noter que le sigle «ISO» n'est l'acronyme de son nom dans aucune de ses langues officielles !
  3. En septembre 1994, on recensait 806 compilateurs validés par 52 compagnies.
  4. En fait, des mesures ont été prises dès 1992 pour permettre d'étendre le jeu de caractères sans attendre la révision de la norme.
  5. Allemagne, Belgique, Canada, Grande-Bretagne, Espagne, France, Japon, Pays-Bas, Russie, Suède, Suisse, USA.

Objectifs du langage

modifier

Contrairement à beaucoup de langages de programmation, Ada n'est pas le fruit des idées personnelles de quelque grand nom de l'informatique, mais a été conçu pour répondre à un cahier des charges précis, dont l'idée directrice était de diminuer le coût des logiciels, en tenant compte de tous les aspects du cycle de vie. Le langage est donc bâti autour de quelques idées-forces :

  • Privilégier la facilité de maintenance sur la facilité d'écriture : le coût de codage représente environ 6% du coût global d'un logiciel ; la maintenance représente plus de 60%.
  • Fournir un contrôle de type extrêmement rigoureux : plus une erreur est diagnostiquée tôt, moins elle est coûteuse à corriger. Le langage fournit des outils permettant de diagnostiquer beaucoup d'erreurs de cohérence dès la compilation.
  • Permettre une programmation intrinsèquement sûre : des contrôles nombreux à l'exécution permettent de diagnostiquer les erreurs dynamiques. Un programme doit être capable de traiter toutes les situations anormales, y compris celles résultant d'erreurs du programme (autovérifications, fonctionnement en mode dégradé).
  • Offrir un support aux méthodologies de programmation : le langage doit faciliter la mise en œuvre des méthodes du génie logiciel, sans exclusive et sans omission.
  • Être portable entre machines d'architectures différentes : les programmes doivent donner des résultats identiques, ou au moins équivalents, sur des machines différentes, y compris en ce qui concerne la précision des calculs numériques.
  • Permettre des implémentations efficaces et donner accès à des interfaces de bas niveau : exigence indispensable à la réalisation notamment de systèmes « temps réel ».
  • Offrir un support à une industrie du composant logiciel : en fournissant des interfaces standard et des garanties de portabilité sur toutes les machines, Ada permet la création d'entreprises spécialisées en composants logiciels, tandis que d'autres réalisent des produits finis (applications) par assemblage de ces composants.

Au-delà du langage

modifier

La définition d'un nouveau langage est une condition nécessaire, mais non suffisante, pour entrer dans une ère de production industrielle de logiciel. Aussi ne peut-il exister de compilateur Ada sans un environnement de programmation associé ; en particulier, le langage définit des règles très strictes concernant les dépendances entre unités de compilation, pour garantir la cohérence globale des différentes unités constituant un programme Ada. Ceci nécessite au moins un outil de gestion de projet associé. En fait, parallèlement à la définition du langage, se poursuivait un effort de définition de l'APSE (Ada Programming Support Environment) qui a abouti au rapport STONEMAN. Celui-ci définit les outils logiciels qui doivent faire partie d'un environnement de programmation Ada.

Aujourd'hui, il existe non seulement des compilateurs de qualité industrielle, mais aussi des outils d'environnement de haut niveau : metteurs au point symboliques, outils de gestion de projet, de documentation et de support méthodologique se sont multipliés. Les objectifs d'Ada sont ceux de tout le mouvement du génie logiciel, et le langage n'est qu'un élément qui s'intègre dans un ensemble, notamment méthodologique, plus vaste.

Exercices

modifier
  1. Étudier comment ont été développés les langages COBOL, C, Pascal et Ada. Quels sont ceux qui avaient un cahier des charges au départ, et comment l'historique explique-t-il les particularités de chacun ?
  2. Une particularité d'Ada est d'avoir été normalisé avant l'apparition du premier compilateur. Expliquer en quoi ceci est un facteur important pour la qualité de la définition du langage.

Présentation du langage

modifier

Ada est un langage algorithmique d'une puissance d'expression considérable, dérivé de Pascal dont il a retenu les structures de contrôle et certains types de données, mais avec en plus des possibilités d'encapsulation de données, de modularité, de modélisation de tâches parallèles, et de traitement des situations exceptionnelles. Ada reste un langage procédural « classique » (contrairement à LISP, SNOBOL, SETL ou Prolog par exemple), mais c'est un langage qui a un spectre sémantique assez vaste, et dont les apports originaux sont surtout du domaine de l'ingénierie du logiciel : fiabilité, maintenabilité, modularité, portabilité, réutilisabilité de composants logiciels.

Ada est-il un langage orienté objet ? Cette question a fait couler beaucoup d'encre. Ada 83 offrait un excellent support aux méthodes objet par composition mais n'avait pas de structure réalisant directement l'héritage ; il s'est ensuivi une querelle de mots pour savoir si Ada était « orienté objet » ou non, et l'utilisation parfois du terme « basé objet » (?) pour décrire les langages tels qu'Ada qui fournissaient indiscutablement la notion d'objet, tout en se passant fort bien de l'héritage... Ce débat n'a plus lieu d'être : la version 95 a apporté le support de l'héritage et des méthodes par classification. Nous discuterons complètement de ces questions dans la deuxième partie du livre, mais retenons que, maintenant, Ada est indiscutablement un langage orienté objet.

Un cadre général proche de Pascal

modifier

Lors de l'appel d'offres qui conduisit à la définition d'Ada, il était spécifié que la proposition de langage devait être fondée sur la base d'un « grand » langage classique (Pascal, PL/1, Algol...). Il est significatif que les quatre langages finalistes avaient tous choisi Pascal comme base de départ.

Le programme ci-dessous est un premier exemple qui imprime « Bonjour », « Bon après-midi » ou « Bonsoir » en fonction de l'heure de la journée.

with Text_IO, Calendar;
use Text_IO, Calendar;

procedure Bonjour is
  Heure : Day_Duration;
begin
  Heure := Seconds(Clock)/3600;
  if Heure < 12.00 then
    Put_Line ("Bonjour");
  elsif Heure < 19.00 then
    Put_Line ("Bon après-midi");
  else
    Put_Line ("Bonsoir");
  end if;
end Bonjour;

Il n'y a pas de construction spéciale pour désigner le programme principal : c'est une procédure comme les autres. La clause with qui est en tête permet d'utiliser deux paquetages (packages) qui fournissent respectivement l'accès aux fonctionnalités d'entrées-sorties (Text_IO) et à l'utilisation du temps (Calendar).

De façon générale, toute unité de compilation (structure pouvant être compilée séparément, comme les sous-programmes et paquetages) doit citer au début les autres unités qu'elle utilise au moyen d'une clause with. La clause use est une simplification d'écriture dont nous reparlerons plus tard.

Les instructions de base sont inspirées de celles de Pascal (Figure 0), tout en tenant compte de ses imperfections reconnues : toutes les instructions se terminent par un point-virgule et les instructions structurées se terminent par un mot clé, éliminant ainsi le besoin de blocs begin..end (ou des accolades {..} de C).

if condition then
  instructions
elsif condition then
  instructions
else
  instructions
end if;

case expression is
  when choix {|choix} =>
    instructions
  when choix {|choix} =>
    instructions
  when others =>
    instructions
end case;

[Etiquette_de_boucle:]
  [while condition] | [for ident. in [reverse] intervalle]
  loop
    instructions
  end loop;
Figure 0: Instructions de base

L'ordre des déclarations n'est pas imposé : il est donc possible de regrouper les types, variables et constantes qui sont logiquement reliés. Signalons aussi la présence de deux originalités fort utiles : les pragmas et les attributs. Un pragma permet de donner des indications au compilateur ; par exemple, si la performance est plus importante pour une application que l'espace mémoire, on pourra en informer l'optimiseur au moyen du pragma :

pragma Optimize (Time);

Les attributs permettent d'obtenir des renseignements (pouvant dépendre de l'implémentation) sur des objets, des types, des sous-programmes... Si l'on souhaite par exemple connaître la taille en bits d'une variable (attribut 'Size), on écrira :

Taille := X'Size;

Nous ne décrirons pas ici tous les pragmas et attributs, mais nous donnerons des précisions lorsque nous en rencontrerons dans le cours du livre.

Sous-programmes

modifier

Les procédures et fonctions (que l'on appelle collectivement des sous-programmes) se présentent de façon similaire à Pascal : un en-tête, suivi de déclarations, puis d'instructions (Figure 1).

  • Procédure
procedure Identificateur [(paramètres)] is
  Déclarations
begin
  Instructions
exception
  Traite-exceptions
end Identificateur;
  • Fonction
function Identificateur [(paramètres)] return Identificateur_de_type is
  Déclarations
begin
  Instructions
exception
  Traite-exceptions
end Identificateur;
  • Paramètres
Identificateur : [out|in|in out] Identificateur_de_type
Figure 1: Sous-programmes

Il n'existe aucune limite au type retourné par une fonction : ce peut être un tableau, un article, une tâche... Le mode de passage des paramètres fait référence à l'usage qui en est fait : les paramètres peuvent ainsi être déclarés in (lecture seulement), out (écriture seulement), ou in out (lecture et écriture). Ceci remplace avantageusement la notion de « passage par valeur » ou de « passage par variable ». Bien sûr, tous les sous-programmes sont récursifs (et réentrants, puisque Ada permet le parallélisme).

Plusieurs sous-programmes peuvent porter le même nom s'ils diffèrent par le type de leurs arguments. Cette possibilité s'appelle la surcharge et permet de donner des noms identiques à des sous-programmes effectuant la même fonction logique sur des types différents : par exemple, toutes les procédures d'impression s'appellent Put quel que soit le type de ce que l'on imprime.

Exceptions

modifier

La notion d'exception fournit un moyen commode de traiter tout ce qui peut être considéré comme « anormal » ou « exceptionnel » dans le déroulement d'un programme. Une exception se comporte comme une sorte de déroutement déclenché par programme depuis une séquence d'exécution « normale » vers une séquence chargée de traiter les cas exceptionnels.

Une exception peut être déclarée par l'utilisateur ; certaines exceptions sont prédéfinies, comme Storage_Error en cas de mémoire insuffisante, ou Constraint_Error en cas de valeur incorrecte affectée à une variable.

Une exception est déclenchée soit implicitement (en cas de non-respect d'une règle du langage à l'exécution), soit explicitement au moyen de l'instruction raise. Un bloc de programme peut déclarer un traite-exception auquel le contrôle sera donné si l'exception citée se produit dans le bloc considéré. Voici un bloc dans lequel un traite-exception permet de renvoyer une valeur par défaut dans le cas où il se produit une division par 0 :

begin
  Résultat := A/B;
exception
  when Constraint_Error =>
    Résultat := Float'Large;
end;

Si une exception n'est pas traitée localement, elle est propagée aux unités appelantes, jusqu'à ce que l'on trouve un traite-exception adapté, ou que l'exception sorte du programme principal, ce qui arrête l'exécution. Une clause spéciale, when others, permet de traiter toutes les exceptions. Voici un exemple de ce que pourrait être un programme principal qui garantirait un arrêt propre, quoi qu'il arrive dans le programme, sans connaissance a priori de la fonctionnalité des éléments appelés :

procedure Principale is
begin
  Faire_Le_Travail;
exception
  when others => Nettoyage;
end Principale;

Il est important de noter que des événements « catastrophiques », comme la saturation de l'espace mémoire, provoquent simplement en Ada la levée d'exceptions prédéfinies, et sont donc traitables par le programme utilisateur.

Le modèle du typage fort

modifier

Le typage fort, même pour les types élémentaires

modifier

Toutes les variables doivent être déclarées et munies d'un type. Le typage est extrêmement strict (beaucoup plus qu'en Pascal – ne parlons pas de C ou de C++), et ceci constitue un atout majeur du langage. Nous y reviendrons souvent.

Un type définit un ensemble de valeurs muni d'un ensemble d'opérations portant sur ces valeurs. En fait, un type Ada représente des entités de plus haut niveau que les types d'autres langages : il représente en effet une entité du domaine de problème, et non une entité machine. Le programmeur exprime les exigences de son problème ; si par exemple il doit représenter des temps avec une précision absolue de 1 ms et des longueurs avec une précision relative de 10-5, il déclarera :

  type Temps    is delta 0.001 range 0.0 .. 86400.0*366;
  type Longueur is digits 5 range 1.0E-16..1.0E25;

C'est le compilateur qui choisira, parmi les types machine disponibles, le plus performant satisfaisant au moins les exigences ainsi exprimées.

Le langage offre une vaste palette de types numériques : types entiers normaux ou modulaires (ces derniers – nouveaux en Ada 95 – sont non signés et munis d'une arithmétique modulaire), types flottants, fixes et décimaux (ces derniers également apportés par Ada 95). Par exemple, la première déclaration ci-dessus correspond à une déclaration d'un nombre point fixe, qui approxime les nombres réels avec une erreur absolue constante, alors que la seconde correspond à une déclaration de nombre point flottant qui approxime les nombres réels avec une erreur relative constante. Ces derniers correspondent aux nombres «réels» des autres langages. Noter la possibilité de limiter l'intervalle de valeurs en plus du type et de l'étendue de la précision[1]. Puisqu'il s'agit d'entités de nature différente, deux variables de types différents sont absolument incompatibles entre elles (on ne peut additionner une longueur et un temps !), même lorsqu'il s'agit de types numériques. Étant donné :

  type Age   is range  0 .. 120;  -- Soyons optimistes
  type Etage is range -3 .. 10;
    A : Age;
    E : Etage;

on peut écrire :

    A := 5;
    E := 5;

mais l'affectation suivante est interdite par le compilateur :

    A := E; -- ERREUR : incompatibilité de types

Autrement dit, on ne peut pas « mélanger des choux et des carottes ». Ada est le seul langage à offrir cette propriété, qui paraît tout à fait naturelle aux débutants en informatique... et pose parfois des problèmes aux programmeurs expérimentés. Il est toujours possible de convertir des types numériques entre eux (le nom du type sert d'opérateur de conversion). Si l'on veut demander à une personne d'aller à l'étage correspondant à son âge, on peut écrire :

    E := Etage(A);

Ceci montre un point fondamental de la « philosophie » Ada : on n'empêche jamais le programmeur d'écrire ce dont il a besoin, mais si ce qu'il demande est potentiellement dangereux, on exige de lui qu'il décrive explicitement le comportement demandé afin d'avertir le futur programmeur de maintenance.

Ada dispose de types énumératifs et de formes habituelles pour les tableaux et enregistrements simples (nous verrons des formes plus perfectionnées par la suite) :

  type Couleurs is (Bleu, Rouge, Vert, Jaune, Blanc);
  type Valeur_Stock is range 0..1000;
  type Stock_Peinture is array (Couleurs) of Valeur_Stock;
  type Voiture is
    record
      Le_Modèle   : String (1..6);
      La_Couleur  : Couleurs;
      La_Longueur : Longueur;
    end record;

Des agrégats permettent de fournir directement des valeurs d'un type structuré :

    Un_Stock : Stock_Peinture;
    Ma_Voiture : Voiture;
  begin
    Un_Stock   := (Rouge => 20, Bleu => 30, others => 0);
    Ma_Voiture := ("Espace", Bleu, 3.50);
  1. 10-16 m représente le diamètre du quark, 1025 m est le diamètre de l'Univers (cf. [Mor82]).

Types et sous-types

modifier

Ada fait une distinction très nette entre la notion de type et la notion de sous-type. Alors que le type décrit les propriétés générales d'une entité du monde réel, le sous-type exprime des restrictions applicables seulement à certains objets du type. Ces restrictions sont appelées contraintes. Une contrainte peut être par exemple la limitation des valeurs à un certain intervalle numérique, ou la précision des bornes d'un type tableau non contraint (dont on a laissé les bornes indéfinies au moyen du symbole «boîte» (<>), comme ci-dessous).

  subtype Adolescent is Age range 12..18;
  -- les adolescents doivent avoir entre 12 et 18 ans

  type Matrice is array (Positive range <>,
                       Positive range <>) of Float;
  -- Toutes les matrices, quelles que soient leurs dimensions

  subtype Matrice_3_3 is Matrice (1..3, 1..3);
  -- Les seules matrices 3x3

L'affectation, le passage de paramètre sont autorisés (à la compilation) si et seulement si les types correspondent ; une exception se produira à l'exécution si les sous-types sont incompatibles. Un sous-type peut toujours être dynamique : ceci permet notamment de choisir les tailles des tableaux lors de l'exécution, sans pour autant recourir à l'utilisation de pointeurs (qui existent par ailleurs dans le langage, où on les appelle des types accès). Ceci permet de définir des sous-programmes travaillant sur des tableaux de bornes quelconques sans perte de contrôle des débordements. Des attributs permettent de connaître les valeurs effectives des bornes. Voici un exemple de sous-programme effectuant le produit de deux matrices :

  Matrice_Error : exception;
  function "*" (X, Y : Matrice) return Matrice is
    Produit : Matrice (X'Range(1), Y'Range(2));
  begin
    if X'First(2) /= Y'First(1) or X'Last(2) /= Y'Last(1)
    then
      raise Matrice_Error;
    end if;
    for I in X'Range (1) loop
      for J in Y'Range (2) loop
        Produit(I,J) := 0.0;
        for K in X'Range (2) loop
          Produit(I,J) := Produit(I,J) + X(I,K) * Y(K,J);
        end loop;
      end loop;
    end loop;
    return Produit;
  end "*";

Ce sous-programme effectue le produit de deux objets de type Matrice dès lors que les bornes de la deuxième dimension de la première matrice correspondent aux bornes de la première dimension de la deuxième matrice (sinon, il y a levée de l'exception Matrice_Error). Grâce aux attributs, le compilateur peut déterminer qu'aucun débordement d'indice n'est possible ; l'optimiseur supprimera tous les contrôles inutiles, et le gain de sécurité apporté par Ada ne se traduira par aucune perte de performances. La possibilité de définir des opérateurs arithmétiques entre types quelconques (y compris tableaux), ainsi que de définir des fonctions renvoyant des types structurés, permet de simplifier considérablement l'utilisation. On pourra écrire :

Mat_1 : Matrice (1..3, 1..5);
Mat_2 : Matrice (1..5, 1..2);
Mat_3 : Matrice (1..3, 1..2);
begin
-- Initialisation de Mat_1 et Mat_2
Mat_3 := Mat_1 * Mat_2;

Types paramétrables

modifier

Ada offre un mécanisme original et extrêmement puissant pour paramétrer un type article de la même façon que l'on peut fournir des paramètres pour diriger le comportement des sous-programmes. Ces paramètres s'appellent discriminants et doivent être d'un type discret (entier ou énumératif), ainsi que d'un type accès (pointeur) en Ada 95. Les discriminants peuvent servir à contrôler la structure de l'article ; ils peuvent être lus, mais ne peuvent être modifiés. Voici par exemple comment représenter des matrices carrées :

  type Matrice_Carrée (Taille : Positive) is
    record
      La_Matrice : Matrice(1..Taille, 1..Taille);
    end record;

Une contrainte de discriminant peut être dynamique ; on peut ainsi lire la taille de la matrice souhaitée :

  Get (N);
  declare
    Ma_Matrice : Matrice_Carrée (N);
  begin
     -- traitements sur la matrice
  end;

Les discriminants peuvent aussi servir à faire des structures dont les composants présents dépendent de leur valeur :

  type Options is (Lettres, Sciences, Techno);
  type Note is delta 0.1 digits 3 range 0.00 .. 20.0;
  type Bulletin (L_Option : Options) is
    record
      Français      : Note;
      Mathématiques : Note;
      case L_Option is
      when Lettres =>
        Latin : Note;
      when Sciences =>
        Physique : Note;
      when Techno =>
        Dessin_Industriel : Note;
      end case;
    end record;

Types dérivés

modifier

Plutôt que de redéfinir un type avec toutes ses propriétés, on peut créer un nouveau type à partir d'un type existant dont il héritera les propriétés (domaine de définition et opérations). Un tel type est appelé type dérivé. Bien sûr, il s'agit d'un type différent, et donc incompatible avec le type d'origine.

  type Mètres is digits 5 range 0.0 .. 1.0E15;
  type Yards  is new Mètres;
  M : Mètres;
  Y : Yards;
  begin
    Y := Y + M; -- Interdit ! On ne peut additionner
                         -- des Yards et des Mètres.

Des types dérivés l'un de l'autre, ou dérivés d'un même ancêtre, sont convertibles entre eux. Ce mécanisme a été enrichi en Ada 95 pour fournir le mécanisme de base de la programmation orientée objet.

Résumé de l'arborescence des types

modifier

Les différentes formes de types et leurs relations sont schématisées à la figure 2 (nous avons inclus quelques types apportés par Ada 95 qui seront présentés dans la prochaine partie).

 
Figure 2: Arborescence des types
Figure 2: Arborescence des types

Ada peut certainement se prévaloir d'être le langage le plus riche en matière de typage !

Paquetages

modifier

Une notion centrale en Ada est celle de paquetage (package). Le paquetage permet de regrouper dans une seule entité des types de données, des objets (variables ou constantes) et des sous-programmes (procédures et fonctions) manipulant ces objets. Un paquetage est constitué de deux parties, compilables séparément : la spécification et le corps. La spécification comporte une partie visible comprenant les informations utilisables à l'extérieur du paquetage, et une partie privée qui regroupe les détails d'implémentation auxquels les utilisateurs du paquetage n'ont pas accès (mais qui sont nécessaires au compilateur) ; ces deux parties sont séparées par le mot clé private.

L'exemple ci-dessous montre la spécification d'un paquetage de gestion de nombres complexes.

  package Nombres_Complexes is
    type Complex is private;
    I : constant Complex;
    function "+" (X, Y : Complex) return Complex;
    function "-" (X, Y : Complex) return Complex;
    function "*" (X, Y : Complex) return Complex;
    function "/" (X, Y : Complex) return Complex;
    function CMPLX (X, Y : Float) return Complex;
    function POLAR (X, Y : Float) return Complex;

  private -- L'utilisateur n'a pas accès aux déclarations ci-dessous
    type Complex is
      record
        Reel : Float;
        Imag : Float;
      end record;
    I : constant Complex := (0.0, 1.0);
  end Nombres_Complexes;

Le type Complex est appelé type privé : dans la partie visible, on ne fait qu'annoncer sa présence, la définition complète étant fournie dans la partie privée (après le mot private) ; l'utilisateur ne peut faire usage des propriétés de l'implémentation. Si on décide par la suite de représenter les nombres complexes sous forme polaire plutôt que cartésienne, les règles du langage garantissent qu'aucune application utilisant ce paquetage n'aura à être modifiée. Un type privé dispose de l'affectation et de la comparaison d'égalité (et d'inégalité). Il est possible de supprimer ces propriétés en déclarant le type comme "limited private" ; le type est alors un type limité.

Une spécification ne définit que l'interface externe du paquetage ; on doit fournir dans le corps du paquetage l'implémentation des fonctionnalités offertes. Par exemple pour la fonction "+", on aurait la forme suivante :

    function "+" (X, Y : Complex) return Complex is
    begin
        -- Implémentation de la fonction "+"
    end "+";

Les paquetages peuvent être compilés séparément, et seule leur spécification est nécessaire pour les utiliser. On réalise ainsi une séparation complète entre les spécifications d'une unité fonctionnelle et son implémentation. De plus, les règles très rigoureuses de recompilation garantissent, d'une part, qu'il n'est pas possible d'utiliser une information cachée d'un paquetage, et d'autre part que la recompilation des unités qui utilisaient une spécification est obligatoire lorsque celle-ci est modifiée : le langage garantit donc la cohérence globale des différentes unités constituant un programme.

Unités génériques

modifier

Les unités génériques sont des unités paramétrables permettant de définir un algorithme indépendamment des types d'objet manipulés. Voici par exemple une procédure générique qui intervertirait ses deux arguments :

  generic
    type Elem is private;
  procedure Permuter (X, Y : in out Elem);

  procedure Permuter (X, Y : in out Elem) is
    Temp : Elem;
  begin
    Temp := X;
    X    := Y;
    Y    := Temp;
  end Permuter;

La déclaration du type Elem comme private signifie que n'importe quel type pour lequel l'affectation (et la comparaison d'égalité) est définie peut faire l'affaire. L'unité générique n'est pas utilisable par elle-même : ce n'est qu'un modèle (on dit parfois un moule) dont on doit faire une instanciation pour obtenir l'unité réelle.

L'instanciation précise les valeurs des paramètres génériques. Ainsi, pour obtenir une procédure permettant d'échanger deux variables de type Age, nous écrirons :

  procedure Permuter_Age is new Permuter (Age);
  ...
  Permuter_Age (Mon_Age, Son_Age);
  ...

L'exemple suivant donne la spécification d'une unité générique de tri de tableau :

  generic
    type Index     is (<>);
    type Composant is private;
    type Tableau   is array (Index) of Composant;
    with function "<" (X,Y : Composant) return Boolean is <>;

  procedure Tri (A_Trier : in out Tableau);

Les paramètres génériques sont le type d'indice du tableau, le type de composant, le type du tableau lui-même, et une fonction de comparaison. Cette fonction de comparaison est munie d'une valeur par défaut (clause is <>), signifiant que si l'utilisateur ne fournit pas de valeur, la comparaison prédéfinie (si elle existe) doit être utilisée. Les avantages de cette démarche sont évidents : à partir d'un algorithme écrit une fois pour toutes, et que l'on pourra optimiser soigneusement, il est possible d'obtenir instantanément les fonctions de tri sur n'importe quel type de données, avec n'importe quel critère de comparaison. On obtient par exemple ainsi une procédure triant un tableau de nombres flottants dans l'ordre croissant :

  type Int is range 1..10;
  type Arr is array (Int) of Float;
  procedure Tri_Ascendant is new Tri (Int, Float, Arr);

Si l'on souhaite trier dans le sens décroissant, il suffit d'inverser le critère de comparaison :

  procedure Tri_Descendant is new Tri (Index     => Int,
                                       Composant => Float,
                                       Tableau   => Arr,
                                       "<"       => ">");

Pour cette instanciation, nous avons utilisé une association nommée de paramètres : la correspondance entre paramètres formels et réels se fait au moyen du nom du paramètre formel, et non pas par la position. Ceci améliore considérablement la lisibilité et évite bien des erreurs. L'association nommée est également possible (et même recommandée) pour les appels de sous-programmes.

Parallélisme

modifier

Le langage Ada permet de définir des tâches, qui sont des unités de programme s'exécutant en parallèle. Les tâches sont des objets appartenant à des types tâche ; elles peuvent donc être membres de structures de données : on peut ainsi définir des tableaux de tâches, des pointeurs sur des tâches, etc.

Les tâches se synchronisent et échangent des informations au moyen d'un mécanisme unique appelé rendez-vous. Une tâche dite serveuse déclare des points d'entrée, dont la spécification ressemble à une spécification de procédure. Une instruction spéciale, accept, lui permet d'accepter un appel du point d'entrée. Une autre tâche peut appeler le point d'entrée à tout moment. Une tâche acceptant une entrée, ou une tâche appelant un point d'entrée, est suspendue jusqu'à ce que son partenaire ait effectué l'instruction complémentaire. Le rendez-vous a alors lieu, la communication s'établissant par échange de paramètres comme pour un appel de procédure. Une fois le rendez-vous terminé, les deux tâches repartent en parallèle. D'autres formes syntaxiques permettent de raffiner ce mécanisme, en permettant l'attente multiple d'un serveur sur plusieurs points d'entrée à la fois, l'attente avec temporisation (time out), l'acceptation conditionnelle, ou la possibilité de terminer automatiquement la tâche si le serveur n'est plus nécessaire.

Ada étant un langage « temps réel », il est possible de suspendre une tâche pendant un certain intervalle de temps et d'accéder à l'heure absolue. Le paquetage Calendar fournit la définition d'un type « temps » (Time) et d'une fonction renvoyant l'heure courante.

Il est possible de mettre une tâche en attente au moyen de l'instruction delay :

  delay 3.0; -- Attente de 3s.

Ada 95 a rajouté une instruction delay until permettant d'attendre jusqu'à une heure absolue.

Voici un exemple de tâche simple permettant de tamponner la transmission d'un caractère entre une tâche productrice et une tâche consommatrice. La spécification annonce que la tâche fournit deux services (Lire et Ecrire) :

  task Tampon is
    entry Lire   (C : out Character);
    entry Ecrire (C : in  Character);
  end Tampon;

Le corps de la tâche décrit l'algorithme utilisé :

  task body Tampon is
    Occupe : Boolean := False;
    Valeur : Character;
  begin
    loop
      select
      when not Occupe =>
        accept Ecrire (C : in Character) do
          Valeur := C;
        end Ecrire;
        Occupe := True;
      or when Occupe =>
        accept Lire (C : out Character) do
          C := Valeur;
        end Lire;
        Occupe := False;
      or terminate;
      end select;
    end loop;
  end Tampon;

La tâche boucle sur une attente multiple (instruction select) de ses entrées Lire et Ecrire. Ces entrées sont munies de gardes (clauses when) qui feront que l'on n'acceptera de servir l'entrée Lire que s'il y a effectivement un caractère dans le tampon, et inversement que l'on n'acceptera Ecrire que si le tampon est vide. La clause terminate assure la terminaison automatique de la tâche lorsque plus aucun client potentiel n'existe.

Utilisation de bas niveau

modifier

Ada étant un langage également destiné à la programmation des systèmes, il permet facilement d'accéder au bas niveau : on peut forcer la représentation machine des structures abstraites, spécifier des traitements d'interruptions, inhiber les vérifications de type, et même inclure du code machine. Toutefois, des précautions ont été prises pour conserver la sécurité même avec ce genre de manipulations.

Clauses de représentation

modifier

Il est possible de spécifier au bit près la représentation des types de données. Normalement, la représentation interne est choisie par le compilateur, et le langage garantit la totale indépendance de l'utilisation des types vis-à-vis de leur représentation. Cependant, il faut parfois imposer une représentation, notamment lors d'interfaçages avec le matériel ou avec des sous-programmes écrits dans d'autres langages. On donne alors au programmeur une vision de haut niveau, tout en imposant la représentation de bas niveau. On peut ainsi décrire une cellule de mémoire écran d'IBM-PC :

  type Couleurs is
     (Noir,         Bleu,          Vert,       Cyan,
      Rouge,        Magenta,       Marron,     Gris,
      Gris_sombre,  Bleu_clair,    Vert_clair, Cyan_clair,
      Rouge_clair,  Magenta_clair, Jaune,      Blanc);

  -- Représentation d'un type énumératif
  for Couleurs use (0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15);

  subtype Couleurs_Fond is Couleurs range Noir .. Gris;

  type Animation is (Fixe, Clignotant);

  -- Représentation d'un autre type énumératif
  for Animation use (0, 1);

  type Attribut is
    record
      Clignotement : Animation;
      Fond         : Couleurs_Fond;
      Devant       : Couleurs;
    end record;

  -- Représentation d'un type article : at <mot> range <bits>
  for Attribut use
    record
      Clignotement   at 0 range 0..0;
      Fond           at 0 range 1..3;
      Devant         at 0 range 4..7;
    end record;

Si l'on veut changer la valeur du bit Clignotement d'un attribut, on écrira simplement :

  A.Clignotement := Fixe;

ce qui aura bien pour effet de changer la valeur du bit considéré. On voit qu'ainsi, il n'est plus jamais besoin de calculer des masques, décalages, etc., pour intervenir à bas niveau. En cas de modification de la représentation interne des données, aucune modification du code n'est nécessaire.

Les règles du langage ne sont nullement changées par la présence de clauses de représentation, ce qui constitue un outil très puissant. Supposons que nous ayons besoin, par exemple lors de l'écriture d'une interface, de deux représentations physiques différentes d'un même type de données. Il suffit de faire un type dérivé muni de clauses différentes de celles d'origine. Les types dérivés étant convertibles entre eux, le changement de représentation sera pris en charge par le compilateur, comme dans l'exemple suivant :

    type Format_1 is ...
    for  Format_1 use ... -- Représentation de Format_1
    type Format_2 is new Format_1;
    for  Format_2 use... --  Représentation de Format_2
    V1 : Format_1;
    V2 : Format_2;
  begin
    ...
    V1 := Format_1(V2); -- Changement de représentation
    V2 := Format_2(V1); -- effectué par le compilateur

Détypage

modifier

Les conversions précédentes étaient des conversions de haut niveau, respectant la sémantique des types. Il est parfois nécessaire d'effectuer des conversions plus directes (type cast), notamment lorsqu'une même donnée doit être vue selon deux niveaux d'abstraction différents. Par exemple, des caractères sont des entités propres, qui ne sont pas considérées comme des valeurs numériques. On ne peut donc additionner des caractères, ce qui n'aurait aucun sens... sauf si l'on veut calculer un CRC[1]. On peut alors forcer une vue d'un caractère sous forme numérique grâce à l'instanciation de la fonction générique Unchecked_Conversion :

    type Octet is mod 256; -- type modulaire
    for  Octet'Size use 8; -- représenté sur 8 bits
    function Char_to_Octet is new Unchecked_Conversion (Character, Octet);
    CRC : Octet;
    C   : Character;
  begin
    ...
    CRC := CRC * 2 + Char_to_Octet (C);

La sécurité des types n'est pas mise en cause, car les règles du langage font que l'utilisation de cette fonction n'est possible qu'à la condition de mettre en tête de module la clause with Unchecked_Conversion. Ceci a deux conséquences importantes :

  • Celui qui relit le programme est immédiatement prévenu de la présence dans le module de fonctionnalités potentiellement dangereuses ou non portables. Réciproquement, l'absence de cette clause signifie qu'il ne peut y avoir de forçage de type.
  • Si les règles de codage du projet imposent une justification spéciale pour l'utilisation d'Unchecked_Conversion, le chef de projet peut s'assurer qu'aucune utilisation non autorisée n'en a été faite simplement en interrogeant son gestionnaire de bibliothèque, sans avoir à inspecter manuellement tout le code : la règle peut donc être commodément vérifiée. Ce point est fondamental : une règle qui serait invérifiable en pratique est lettre morte dans un projet quelque peu important.
  1. Cyclic Redundancy Code, code utilisé pour vérifier la validité de données, et fondé sur une valeur clé obtenue par des manipulations arithmétiques sur les codes des caractères.

Compilation séparée

modifier

Favoriser la modularité des programmes était une contrainte forte dans la définition du langage. Il fallait faire en sorte que l'on n'ait jamais intérêt à faire des modules trop gros, donc difficiles à maintenir.

Ceci a été obtenu au moyen de la notion de bibliothèque de programme. Une bibliothèque Ada est différente de ce que l'on appelle « bibliothèque » dans d'autres langages de programmation ; c'est une sorte de base de données conservant les produits des compilations : codes objets bien entendu, mais également informations sémantiques sur les modules (résultats de la compilation des spécifications), et aussi souvent informations annexes : dates de première et dernière recompilations, copie des sources, noms des fichiers d'origine...

La compilation d'une unité Ada consiste à la rajouter à cette bibliothèque, tout en mémorisant ses liens de dépendance (clauses with), ainsi que toutes les caractéristiques des entités exportées par les spécifications.

Ceci permet de garantir des contrôles aussi stricts, notamment en matière de typage, pour des unités compilées séparément que pour des unités compilées en même temps. Le compilateur refusera de construire un programme incohérent. On obtient ainsi le bénéfice des systèmes de contrôle externes utilisés dans d'autres langages (makefile), mais gérés automatiquement par le compilateur. On dit que le langage Ada fournit la possibilité de compilations séparées, mais non indépendantes.

Le processus de construction d'un programme en Ada est donc très différent de ce qui se passe dans les autres langages. On construit tout d'abord (avec un utilitaire fourni avec le compilateur) une bibliothèque vide. Puis l'on ajoute, module par module, des unités à cette bibliothèque. On bâtit ainsi progressivement le programme, en assemblant les différents morceaux qui le constituent comme les pièces d'un puzzle. On commencera par mettre dans la bibliothèque les spécifications communes à l'ensemble du projet, puis chaque programmeur codera les implémentations de ces spécifications ; le langage garantira la cohérence des implémentations par rapport aux spécifications aussi bien que celle des appels par l'utilisateur. De plus, tous les compilateurs possèdent des raffinements personnels améliorant ce modèle : bibliothèques liées (bibliothèques physiquement différentes, mais logiquement reliées), sous-bibliothèques fournissant des règles de visibilité des modules (un module est recherché dans la sous-bibliothèque avant d'être recherché dans la bibliothèque principale), familles de bibliothèques...

Ada 95 a quelque peu assoupli le modèle de bibliothèque, notamment pour diminuer les recompilations et permettre d'autres modèles de gestion de modules. Mais le principal a été conservé : il est impossible de construire un programme incohérent.

Exercices

modifier
  1. Prendre n'importe quel programme d'exercice en C ou en Pascal, et le traduire directement en Ada. Reprendre le programme précédent et le recoder en utilisant au mieux les fonctionnalités d'Ada non présentes dans les autres langages. Comparer la puissance d'expression des deux versions.
  2. Comparer les contrôles de type offerts par Ada avec ceux d'autres langages. Pourquoi aucun autre langage n'a-t-il été aussi loin dans les contrôles ?
  3. Comparer les mécanismes de compilation séparée de divers langages avec ceux d'Ada.

Les nouveautés d'Ada 95

modifier

Les nouveautés d'Ada 95 sont organisées autour de quelques grands thèmes : extensions pour la programmation orientée objet, nouvelles fonctionnalités pour la programmation système et le temps réel, meilleur support de très gros projets... et correction des problèmes connus d'Ada 83. D'autre part, la nouvelle norme est en deux parties, séparant le langage proprement dit de la bibliothèque prédéfinie. Les besoins spécifiques de domaines particuliers sont pris en compte dans des annexes facultatives.

Perfectionnements généraux

modifier

Le jeu de caractères de base est maintenant Latin 1 (8 bits) et non plus ASCII (7 bits). Le compilateur peut offrir des options permettant de gérer d'autres jeux de caractères, tels que Latin 2 ou IBM/PC. Les lettres accentuées sont autorisées même dans les identificateurs.

Ada 95 fournit un support direct de l'arithmétique décimale, exigence indispensable à son succès en informatique de gestion. Les types décimaux sont des types point-fixe, mais à base décimale au lieu de binaire. On notera également l'introduction de types entiers modulaires (les opérations « bouclent » au lieu de déborder) et l'ajustement du modèle de l'arithmétique aux évolutions du matériel (arithmétique IEEE notamment).

De nouvelles formes de paramètres génériques (passage de paquetage générique en paramètre formel générique) facilitent la combinaison de fonctionnalités, en supprimant une cause majeure de duplication de code. Le plus simple est de l'illustrer par un exemple :

  generic
    type Real is digits <>;

  package Nombres_Complexes is
    ...
  end Nombres_Complexes;

  generic
    with Matrices_Complexes is new Nombres_Complexes(<>);

  package Matrices_Complexes is
    ...
  end Matrices_Complexes;

Lors de l'instanciation du paquetage Matrices_Complexes, on passera n'importe quel paquetage obtenu par instanciation du paquetage Nombres_Complexes :

  -- Simple précision
  package Complexes_Simples is new Nombres_Complexes(Float);
  package Matrices_Complexes_Simples is new Matrices_Complexes (Complexes_Simples);

  -- Double précision
  package Complexes_Doubles is new Nombres_Complexes(Long_Float);
  package Matrices_Complexes_Doubles is new Matrices_Complexes (Complexes_Doubles);

Enfin de nouvelles formes de pointeurs sont autorisées : pointeurs sur objets globaux et pointeurs sur sous-programmes facilitent les interfaces avec des systèmes extérieurs, systèmes de fenêtrage notamment. Des contrôles à la compilation comme à l'exécution garantissent l'impossibilité de référencer un objet qui n'existerait plus, comme un objet local à un sous-programme après la sortie de celui-ci par exemple.

Extensions pour la programmation orientée objet

modifier

De nombreux utilisateurs critiquaient le manque de support d'Ada pour le paradigme d'héritage. Ce point était cependant délicat, car l'héritage s'oppose à certains des principes d'Ada, entraînant notamment un affaiblissement du typage (certaines cohérences de type ne peuvent être vérifiées qu'à l'exécution, alors qu'Ada les assure traditionnellement lors de la compilation). Enfin, la programmation orientée objet conduit souvent à une multiplication de pointeurs et d'allocations dynamiques cachées. Or le monde du temps réel, particulièrement pour les applications devant fonctionner 24 heures sur 24, est très hostile à l'allocation dynamique à cause des risques de fragmentation mémoire et de perte d'espace qu'elle induit. Il fallait donc introduire un mécanisme permettant d'ouvrir Ada à de nouvelles applications, sans lui faire perdre ses principes fondamentaux : primauté de la facilité de maintenance sur la facilité de conception, contrôle précis de l'allocation et vérifications strictes lors de la compilation.

La solution retenue est fondée sur une extension naturelle du mécanisme des types dérivés : les types étiquetés (tagged). Nous ne présenterons ici que quelques lignes directrices, car nous en détaillerons l'utilisation dans la deuxième partie de ce livre. Il s'agit de types munis d'un attribut supplémentaire caché permettant d'identifier le type d'un objet à l'intérieur de sa classe (ensemble des types dérivés directement ou indirectement d'un même ancêtre).

Ces types offrent trois nouvelles possibilités :

  • L'extension de types. Il est possible, lorsque le type parent d'un type dérivé est un type étiqueté, d'ajouter des champs supplémentaires lors de la dérivation. Les opérations du parent restent disponibles, mais n'opèrent que sur la partie commune.
  • Le polymorphisme. Des sous-programmes peuvent posséder des paramètres se référant à la classe d'un type étiqueté : ils sont applicables alors non seulement au type d'origine, mais également à tous les types qui en sont dérivés. Il est possible de définir des pointeurs sur classe, pouvant désigner n'importe quel objet appartenant à la classe. Cette dernière forme de polymorphisme permet l'implémentation des mécanismes habituels de la programmation orientée objet, tout en conservant le contrôle explicite des pointeurs.
  • Les liaisons dynamiques. Si on appelle un sous-programme en lui passant un paramètre de type classe, le sous-programme appelé n'est pas connu à la compilation. C'est l'étiquette (tag) de l'objet réel qui détermine à l'exécution le sous-programme effectivement appelé. Nous reviendrons sur ce mécanisme lors de l'étude des méthodes par classification, mais remarquons qu'il n'exige pas de pointeurs : contrairement à nombre de langages orientés objet[1], on peut effectuer des liaisons dynamiques sans imposer de pointeurs, cachés ou non.

Généralement, les langages orientés objet fournissent toutes ces possibilités « en bloc ». En Ada 95, l'utilisateur ne demandera que les fonctionnalités dont il a besoin, et ne paiera que le prix de ce qu'il utilise. Là où l'extensibilité et la dynamicité sont plus importantes que la sécurité, Ada offrira toutes les fonctionnalités nécessaires. En revanche, un concepteur qui ne souhaiterait pas utiliser ces nouvelles fonctionnalités conservera le même niveau de contrôle de type et la même efficacité du code généré qu'avec Ada 83.

On notera que l'héritage multiple est obtenu au moyen des autres blocs de base du langage plutôt que par une construction syntaxique spéciale. Ceci n'est pas une omission, mais un choix délibéré : le risque de complexité lié à ce mécanisme aurait été trop important, et les autres perfectionnements, notamment concernant les génériques, ont permis de construire tous les cas d'héritage multiple que l'on peut rencontrer dans la littérature.

  1. Dont Eiffel, Java, C# et C++. Noter qu'en Java, tout est pointeur, ceux-ci n'apparaîssent donc plus explicitement. Cela ne signifie pas qu'ils ne sont pas là !

Nouvelles facilités pour la gestion de très grosses applications

modifier

Une grande force du langage Ada est son contrôle automatique de configuration : le compilateur vérifie toutes les dépendances entre modules compilés séparément, et aucun module ne peut référencer un module périmé. Dans de grosses applications, ceci peut conduire à des recompilations massives, d'autant plus regrettables que la modification n'est bien souvent due qu'à l'ajout de fonctionnalités pour un besoin nouveau, ce qui ne perturbait pas en fait les anciens utilisateurs. D'autre part, il est souvent nécessaire de regrouper logiquement des paquetages concourant à un but commun ; un tel ensemble est appelé « sous-système » dans la terminologie de Booch. Des environnements de programmation sont actuellement capables de gérer de tels sous-systèmes, mais il n'y avait pas de support direct au niveau du langage.

Ada 95 introduit la notion de paquetages hiérarchiques. Il est possible de créer des paquetages « enfants » qui rajoutent des fonctionnalités à des paquetages existants sans avoir à toucher à leurs « parents », donc sans recompilation des utilisateurs de ces parents. Ceci permet une meilleure séparation en fonctionnalités « principales » et fonctionnalités « annexes », facilitant l'utilisation aussi bien que la maintenance. Par exemple, lorsque l'on fait un type de donnée abstrait, on ne souhaite généralement pas mélanger les propriétés de base avec les entrées-sorties. Ceci peut s'obtenir de la façon suivante :

package Nombres_Complexes is -- encore lui !
  type Complex is private;
    -- Opérations sur les nombres complexes
  private
    type Complex is ...
end Nombres_Complexes;

package Nombres_Complexes.IO is -- Un enfant
    -- Entrées-sorties sur les complexes
end Nombres_Complexes.IO;

Le corps du paquetage enfant a accès à la partie privée du parent, autorisant donc des implémentations efficaces fondées directement sur la représentation interne du type abstrait. De plus, il est possible de définir des enfants « privés », non accessibles depuis les unités extérieures à la famille. On obtient ainsi des contrôles supplémentaires, puisque seul le parent est visible de l'extérieur et constitue le point d'entrée obligé du sous-système dont les autres éléments sont cachés.

Nouvelles facilités pour le temps réel et la programmation système

modifier

Parallélisme et synchronisation

modifier

Un certain nombre de perfectionnements ont été apportés à la demande des utilisateurs. Si, comme en Ada 83, chaque tâche peut être munie d'une priorité d'exécution, celle-ci n'a plus à être statique. La priorité initiale est définie par une déclaration dans la tâche, et un paquetage prédéfini fournit les services permettant de changer dynamiquement cette priorité.

Les types tâches peuvent être munis de discriminants, qui servent à paramétrer leur comportement. On peut transmettre ainsi à la tâche des valeurs simples (notamment l'espace mémoire requis ou la priorité d'exécution), mais également des pointeurs sur des blocs de paramètres, sur d'autres tâches...

Ada 95 a rajouté une nouvelle forme d'appel d'entrée, le select asynchrone, qui permet à un client de continuer à travailler pendant qu'une demande de rendez-vous est active, ou après avoir armé un délai. Si la demande est servie, ou si le délai expire, avant la fin du traitement associé, celui-ci est avorté ; sinon, c'est la demande qui est annulée. Les deux formes de select asynchrone sont montrées ci-dessous :

  select
    appel d'entrée
  then abort
    instructions
  end select;
  select
    delay [until] ...;
  then abort
    instructions
  end select;

La synchronisation et la communication entre tâches étaient fondées en Ada 83 sur un mécanisme unique : le rendez-vous. Pour protéger une variable contre des accès simultanés, il fallait l'enfermer dans une tâche « gardienne ». Ce mécanisme fonctionnait bien ; il conduisait cependant à une multiplication des petites tâches, et la communauté temps réel souhaitait un moyen simple, plus léger et très performant de protéger des variables.

La solution proposée par Ada 95 s'appelle les articles protégés. Il s'agit d'objets (ou de types d'objets) auxquels sont associés des sous-programmes spéciaux, dont on garantit l'exclusivité d'accès. Ils s'apparentent donc aux moniteurs de Hoare, mais en plus perfectionné (possibilité de mettre des barrières notamment). Voici un tel objet protégé :

  protected Barrière is
    entry Passer;
    procedure Ouvrir;
    function En_Attente return Natural;
  private
    Ouverte : Boolean := False;
  end Barrière;

  protected body Barrière is
    entry Passer when Ouverte is
    begin
      if Passer'Count = 0 then -- Le dernier referme
        Ouverte := False; -- la barrière.
      end if;
    end Passer;    
    procedure Ouvrir is
      begin
        if En_attente > 0 then
          Ouverte := True;
        end if;
      end Ouvrir;
    function En_Attente return Natural is
      begin
        return Passer'Count;
      end En_Attente;
  end Barrière;

Une tâche appelant l'entrée Passer est bloquée jusqu'à ce qu'une autre tâche appelle la procédure Ouvrir ; toutes les tâches en attente sont alors libérées. La fonction En_Attente permet de connaître le nombre de tâches en attente. Pour bien comprendre cet exemple, il faut savoir que :

  • Plusieurs tâches peuvent appeler simultanément une fonction de l'objet protégé, ou (exclusivement) une seule tâche peut exécuter une procédure ou une entrée (algorithme des lecteurs/écrivains).
  • Des tâches susceptibles de s'exécuter parce que la condition associée à une entrée est devenue vraie s'exécutent toujours avant toute autre tâche appelant une opération de l'objet protégé.

Par conséquent, les tâches libérées lorsqu'un appel à Ouvrir a remis la variable Ouverte à True reprendront la main avant toute tâche non bloquée sur l'entrée. Une tâche qui appellerait l'entrée Passer juste après qu'une autre a appelé Ouvrir se bloquera donc bien (après que toutes les tâches en attente auront été libérées) ; aucune condition de course n'est possible.

Il est possible (avec les implémentations supportant l'annexe « temps réel ») d'associer une priorité à un objet protégé, qui est une priorité « plafond » : toute opération protégée s'effectuera à ce niveau de priorité, et il est interdit à une tâche de niveau de priorité supérieure d'appeler ces opérations protégées. Ce mécanisme permet de garantir l'absence de phénomènes d'inversion de priorité[1]; de plus, sur un système monoprocesseur, il est suffisant pour garantir l'exclusivité d'accès aux objets protégés, sans nécessiter de sémaphore supplémentaire, ce qui rend les objets protégés particulièrement efficaces.

Retenons sur le plan philosophique l'introduction dans Ada d'un mécanisme de synchronisation par les données, ce qui représente une nouveauté importante par rapport à la version précédente du langage.

Une autre fonctionnalité importante est l'instruction requeue qui permet, pendant le traitement d'un accept de tâche ou d'un appel d'une entrée d'objet protégé, de renvoyer le client en attente sur une autre queue. Par exemple :

  type Urgence is (Lettre, Télégramme);
    ...
  accept Envoyer (Quoi : Urgence; Message : String) do
    if Quoi = Télégramme then
      -- On s'en occupe tout de suite
    else
      requeue Voir_Plus_Tard (Quoi, Message);
    end if;
  end Envoyer;

Ici une tâche serveuse de messages dispose d'un point d'entrée unique pour des messages urgents (les télégrammes) ou moins urgents (les lettres). Elle traite les télégrammes immédiatement, mais renvoie les lettres sur son entrée Voir_Plus_Tard, qu'elle ira traiter lorsqu'il n'y aura plus de messages en attente sur Envoyer.

  1. Cas qui se produit lorsqu'une tâche de haute priorité est bloquée par une de plus basse priorité à cause d'un accès à une ressource commune.

Gestion du temps

modifier

S'il existe plusieurs bases de temps dans le système, l'implémentation a le droit de fournir d'autres types « temps » en plus du type prédéfini Calendar.Time. L'annexe « temps réel » impose la présence d'un temps « informatique », garanti monotone croissant[1] (dans le paquetage Ada.Real_Time). Il est possible de mettre une tâche en attente jusqu'à une heure absolue par l'instruction delay until :

  delay until Time_Of((1995, 02, 15, 08*Heure+30*Minute));

L'attente absolue est exprimée dans un quelconque « type temps » (au moins Calendar.Time). Si l'implémentation en fournit plusieurs, c'est le type de l'argument qui déterminera l'horloge utilisée. Ce mécanisme permet de disposer d'instructions liées au temps portables, tout en bénéficiant de l'accès à des horloges spécifiques si la précision est plus importante que la portabilité.

  1. Le temps par défaut peut correspondre à l'heure légale, et donc subir des sauts vers l'avant ou vers l'arrière lors des passages heure d'été / heure d'hiver.

Contrôle de l'ordonnancement et gestion des queues

modifier

Normalement, l'ordonnancement des tâches est géré par l'exécutif fourni avec le compilateur. Ada 95 autorise plusieurs politiques d'ordonnancement, sélectionnables au moyen d'un pragma. Une seule est standardisée : FIFO_Within_Priorities, qui exprime que les tâches sont ordonnancées dans l'ordre où elles deviennent candidates, les tâches moins prioritaires ne s'exécutant que si aucune tâche plus prioritaire n'est susceptible de prendre le contrôle d'un processeur. Si cette politique est choisie, alors il est obligatoire de spécifier également le plafond de priorité pour les types protégés. Une implémentation est autorisée à fournir d'autres politiques d'ordonnancement.

Cependant, de nombreuses applications temps réel souhaitent un contrôle plus précis de l'ordonnancement. Dans ce but, l'annexe « programmation système » fournit des attributs et un paquetage permettant de récupérer le « Task_ID » interne d'une tâche (en particulier, de l'appelant dans un rendez-vous) et d'effectuer certaines opérations dessus. Un paquetage permet de créer des attributs de tâches, sortes de variables associées à chaque tâche (concrètement, cela signifie que l'on rajoute des variables utilisateur dans le bloc de contrôle de tâche).

À ces fonctionnalités, l'annexe « temps réel » rajoute un paquetage de contrôle synchrone fournissant une sorte de sémaphore sur lequel les tâches peuvent venir se bloquer, et un paquetage de contrôle asynchrone permettant de suspendre et de relancer n'importe quelle tâche dont on connaît le « Task_ID ». L'ensemble de ces fonctionnalités permet à l'utilisateur de contrôler entièrement l'ordonnancement des tâches ; en particulier, les attributs permettent de stocker dans chaque tâche les données nécessaires à la détermination de la prochaine tâche à ordonnancer.

En ce qui concerne la gestion des queues, les requêtes de rendez-vous et les files d'attente des entrées protégées sont traitées par défaut dans l'ordre premier arrivé - premier servi. L'annexe « temps réel » fournit un pragma (Queing_Policy) demandant de traiter les requêtes par ordre de priorité (les tâches plus prioritaires « doublent » les tâches moins prioritaires dans la queue) ; toute implémentation est autorisée à fournir des pragmas définissant d'autres algorithmes de mise en queue.

Gestion des interruptions

modifier

Ada 83 faisait gérer les interruptions par des tâches, qui associaient des entrées à des interruptions physiques. À l'usage, ce mécanisme s'est révélé peu conforme à l'attente des utilisateurs temps réel. Il existe toujours en Ada 95 (ne serait-ce que par compatibilité ascendante), mais est classé « obsolète », ce qui signifie que l'on est censé lui préférer le nouveau mécanisme, l'attachement d'interruptions à des procédures protégées. Ce mécanisme fait partie de l'annexe « programmation système ».

Il existe deux moyens d'attacher une procédure protégée à une interruption : statiquement, au moyen de pragmas, ou dynamiquement au moyen de sous-programmes fournis dans le paquetage Ada.Interrupts. Ce paquetage fournit également des fonctionnalités permettant de savoir si une procédure est actuellement attachée à une interruption, d'attacher une interruption à une procédure protégée en récupérant un pointeur sur le traitement d'interruption précédemment attaché (pour le rétablir plus tard), etc.

Le choix de représenter les traitements d'interruption par des procédures protégées plutôt que par des sous-programmes normaux permet d'exprimer de façon commode la nature non réentrante des traitements d'interruptions. Il permet également d'interdire l'utilisation d'instructions bloquantes, comme delay ou les appels d'entrées, depuis les gestionnaires d'interruption.

Flots de données

modifier

Un flot de données est un type descendant du type (étiqueté) Root_Stream_Type défini dans le paquetage Ada.Streams. Il représente une suite d'octets et est muni de procédures Read et Write permettant de lire et d'écrire des tableaux d'octets dans le flot. En plus de cette utilisation de premier niveau, l'intérêt des flots de données vient de ce que le langage fournit (sous forme d'attributs) des procédures permettant de lire et d'écrire tout type de donnée (non limité) depuis ou vers un flot de données. L'utilisateur peut redéfinir lui-même ces procédures s'il souhaite une représentation externe différente.

Il peut aussi définir ces procédures pour des types limités. On peut ainsi passer de façon sûre et portable d'une vue abstraite d'un type de donnée vers un simple ensemble d'octets. Comme les fonctionnalités des flots permettent de convertir ces octets depuis/vers n'importe quel type, on peut créer des fichiers de données hétérogènes (contenant des valeurs provenant de types différents) sans perdre les avantages du typage fort. C'est en particulier indispensable pour transmettre des types de haut niveau sur un réseau, ou si l'on veut écrire des méthodes d'accès pour des fichiers indexés ou autre.

Concrètement, un flot peut stocker ses données en mémoire, dans un fichier, à travers un réseau ou par tout autre moyen. Le langage prédéfinit le paquetage Ada.Streams.Stream_IO qui fournit une implémentation des flots de données dans un fichier binaire, et Ada.Text_IO.Text_Stream qui utilise un fichier texte.

Contrôle de l'allocation mémoire

modifier

Le paquetage System.Storage_Elements fournit la notion de tableau d'octets (Storage_Array), ainsi que le type Integer_Address, qui représente une adresse sous forme de nombre entier, et est muni de fonctions de conversion sûres depuis/vers le type System.Address. Le paquetage System.Address_To_Access_Conversion procure des fonctionnalités de conversion (propres !) entre types accès et adresses. Il est donc possible d'accéder directement à la mémoire sans recourir à des utilisations douteuses (et souvent non portables) de Unchecked_Conversion.

Enfin le paquetage System.Storage_Pools fournit un type de données permettant à l'utilisateur de définir des « objets pools » et de les associer à des types accès. L'allocation et la désallocation dynamiques (opérations new et Unchecked_Deallocation) utilisent alors obligatoirement ces objets. L'utilisateur contrôle donc entièrement les algorithmes d'allocation/désallocation de mémoire dynamique, et peut garantir l'absence de fragmentation, le temps maximum d'une allocation dynamique, etc., indépendamment de la bibliothèque d'exécution (run-time) du compilateur.

Nouveaux paquetages prédéfinis

modifier

Ada 83 n'offrait que peu de paquetages prédéfinis, principalement des fonctionnalités d'entrées-sorties. Le langage était suffisamment puissant pour permettre à l'utilisateur d'écrire lui-même tout ce dont il avait besoin. À l'usage, il est apparu cependant que l'utilisateur aurait préféré trouver un environnement plus complet, plutôt que de devoir tout refaire lui-même. Ada 95 offre donc un plus grand nombre de paquetages, fournissant les fonctionnalités faisant partie généralement de la définition des autres langages.

Nous donnons ici un bref survol de cette nouvelle bibliothèque standard. Tous ces paquetages doivent être obligatoirement fournis par tout compilateur validé. Nous n'avons pas inclus les paquetages définis au niveau des annexes (cf. prochaine section). Remarquons que la plupart de ces paquetages n'ont nullement besoin de faire appel aux nouvelles fonctionnalités « 95 ». Les personnes programmant actuellement encore en Ada 83 peuvent donc les utiliser (il existe une implémentation dans le domaine public).

Organisation générale

modifier

Le concept d'unités hiérarchiques a permis d'organiser tous les paquetages de façon logique. Toutes les unités de compilation sont des enfants de Standard. Les éléments prédéfinis sont regroupés comme enfants de trois paquetages principaux : Ada, pour les unités indépendantes de l'implémentation, Interfaces, pour ce qui concerne la liaison avec d'autres langages, et System pour ce qui concerne les éléments dépendant de l'implémentation. Text_IO est ainsi devenu Ada.Text_IO. Des surnommages permettent d'assurer la compatibilité avec les programmes utilisant les anciens noms. La hiérarchie complète de l'environnement est résumée à la figure 3 (nous avons inclus les paquetages définis dans les annexes, décrits dans la prochaine section).

 
Figure 3: Organisation de l'environnement standard
Figure 3: Organisation de l'environnement standard

Le paquetage Standard

modifier

La seule modification apportée au paquetage Standard (qui définit les éléments directement utilisables par tout programme) est l'addition du type Wide_Character (caractères 16 bits) et d'un type Wide_String associé.

Chaînes et caractères

modifier

Le paquetage Ada.Characters définit des fonctions de test (Is_control, Is_Upper, Is_Decimal_Digit...) et de conversion (To_Lower, To_Upper...) sur les caractères, ainsi qu'entre Character et Wide_Character. Son enfant Ada.Characters.Latin_1 contient des définitions de constantes nommant tous les caractères du jeu Latin 1.

Plusieurs paquetages offrent des fonctionnalités assez évoluées de gestion de chaînes de caractères et d'ensembles de caractères. Ces fonctionnalités utilisent le type String (et Wide_String) ou des types définis dans les paquetages pour des chaînes de longueur variable soit bornée (Bounded_String), soit non bornée (Unbounded_String).

Traitement numérique

modifier

Le paquetage de fonctions élémentaires (Sin, Sqrt, etc.) qui était déjà une norme secondaire d'Ada 83, fait désormais partie de l'environnement standard. De plus, il existe des paquetages de nombres aléatoires[1].

  1. Notons pour la petite histoire que la formulation du générateur aléatoire a été l'un des points les plus débattus de l'histoire d'Ada 95, et pour lequel le consensus a été le plus difficile à obtenir...

Entrées-sorties

modifier

Le paquetage Ada.Text_IO s'est vu rajouter les nouveaux sous-paquetages génériques Modular_IO et Decimal_IO pour les types modulaires et décimaux, respectivement. Modular_IO est semblable à Integer_IO, et Decimal_IO à Fixed_IO. Text_IO s'est également enrichi d'un nouveau fichier prédéfini, Standard_Error, correspondant à la sortie d'erreur que l'on trouve sur de nombreux systèmes d'exploitation, d'une procédure Get_Immediate permettant de lire un caractère « au vol » sans attendre de retour chariot et d'une procédure Look_Ahead permettant d'anticiper sur le prochain caractère à lire.

Un nouveau paquetage Ada.Wide_Text_IO est semblable à Text_IO, mais travaille avec des Wide_Character et des Wide_String.

Interfaces

modifier

Le paquetage Interfaces regroupe des enfants pour les différents langages dont l'interfaçage est supporté. Les spécifications de Interfaces.C, Interfaces.FORTRAN et Interfaces.COBOL sont données dans la norme. On y trouve les déclarations Ada des types de ces langages, ainsi que des fonctionnalités nécessaires à une bonne liaison. C'est ainsi que, pour C, le paquetage enfant Interfaces.C.Strings gère des chaînes de caractères « à la C » (pointeur sur un flot d'octets terminé par un caractère NUL), et que Interfaces.C.Pointers gère une arithmétique sur pointeurs.

Nous ne pouvons qu'insister sur la facilité d'interfaçage apportée par ces paquetages ; dans le cadre du projet GNAT (cf. paragraphe le compilateur GNAT), nous avons implémenté une interface complète avec le système d'exploitation OS/2 (en utilisant la bibliothèque C) ; grâce à ces paquetages, tout a fonctionné sans problème dès le premier essai !

Annexes

modifier

Ada est un langage à spectre très large ; ses plus beaux succès se trouvent aussi bien dans le contrôle de simulateurs ou l'aéronautique qu'en gestion financière ou en CAO. Or il est évident qu'un utilisateur « temps réel » n'aura pas les mêmes exigences qu'un utilisateur « gestion ». C'est pourquoi la norme définit certains éléments comme « dépendants de l'implémentation », afin de ne pas privilégier un type d'application aux dépens d'un autre. D'autre part, Ada est un langage extensible : son mécanisme de paquetages permet de rajouter toutes les fonctionnalités voulues sans avoir à toucher au langage lui-même.

Ada 95 est muni d'un certain nombre d'annexes correspondant à divers domaines d'utilisation. Elles précisent des éléments du langage autrement laissés libres, et définissent des paquetages spécifiques aux différents domaines. Les annexes ne contiennent ni nouvelle syntaxe, ni modification des règles du langage telles que définies dans la norme principale. Les fournisseurs de compilateurs seront libres de passer une, plusieurs, ou même aucune des suites de tests correspondant aux différentes annexes. Bien entendu, le certificat de validation mentionnera quelles annexes ont été passées.

Si la validation n'est accordée pour une annexe que si elle est entièrement implémentée, il n'est pas interdit de n'en fournir qu'une partie, à condition que les fonctionnalités offertes soient conformes à la définition de la norme. Il sera donc possible d'offrir, par exemple, le paquetage de gestion des nombres complexes même si des contraintes matérielles empêchent de valider totalement l'annexe numérique (précision de la machine insuffisante par exemple).

Il est important de comprendre que ce mécanisme n'est pas une rupture avec le dogme d'unicité du langage ; au contraire, il augmente la portabilité des applications en poursuivant la standardisation d'éléments spécifiques d'un domaine au-delà de ce qu'il était possible de faire dans le cadre d'un langage général. On trouve ainsi six annexes :

  • L'annexe sur la programmation système décrit des paquetages et des pragmas permettant une gestion plus directe des interruptions, la pré-élaboration de paquetages (mécanisme facilitant la mise en mémoire morte de parties de systèmes) et des manipulations supplémentaires de bas niveau sur les tâches. La documentation devra comporter obligatoirement des précisions sur l'efficacité en temps d'exécution de certains mécanismes.
  • L'annexe sur les systèmes temps réel rajoute des fonctionnalités de gestion des priorités (priorités dynamiques notamment), d'ordonnancement des tâches et de gestion des files d'attente (files prioritaires). Elle comporte des exigences sur les délais avant avortement et la précision de l'instruction delay, un modèle de tâches simplifié permettant une implémentation extrêmement efficace, et de nouvelles fonctionnalités de gestion du temps. Noter qu'un compilateur enregistré comme supportant cette annexe devra obligatoirement supporter également l'annexe sur la programmation système.
  • L'annexe sur les systèmes distribués permet de définir un programme comme un ensemble de partitions s'exécutant sur des processeurs différents. Le programmeur peut contrôler le format des messages échangés entre les différentes partitions, et il est possible de remplacer dynamiquement une partition sans relancer tout le système. Cette annexe définit également un modèle d'appel de sous-programme distant (remote procedure call). Vu son importance, nous la détaillerons quelque peu ci-dessous.
  • L'annexe sur les systèmes d'information fournit de nouvelles fonctionnalités de présentation des données (utilisant des clauses picture analogues à celles de COBOL) et des paquetages d'arithmétique complémentaire sur les nombres décimaux. Cette annexe s'est réduite au fil du temps, car une grande partie de ce qu'elle comprenait a été jugée tellement importante qu'elle a été remise dans le corps du langage !
  • L'annexe sûreté et sécurité impose des contraintes supplémentaires au compilateur sur le traitement des cas appelés « erronés » dans le langage et sur un certain nombre de points laissés à la discrétion de l'implémentation. De plus, le compilateur doit fournir tous les éléments (listings) permettant de contrôler manuellement le code généré et offrir des pragmas permettant la vérification par des outils extérieurs.
  • L'annexe sur les numériques fournit un modèle encore plus précis des calculs réels, notamment pour les machines utilisant le modèle IEEE des nombres flottants, et inclut la définition des paquetages de nombres complexes, ainsi que les fonctions élémentaires et les entrées-sorties sur ces nombres complexes.

Systèmes répartis

modifier

L'annexe systèmes distribués définit des éléments supplémentaires permettant d'écrire des systèmes répartis. Ada 95 est le premier langage à inclure un tel modèle au niveau de la définition du langage, donc de façon portable et indépendante des systèmes d'exploitation ou des exécutifs sous-jacents.

Partitions

modifier

Un programme Ada est constitué d'un ensemble de « partitions » faiblement couplées, susceptibles de se trouver physiquement sur des machines différentes. Les partitions peuvent être actives ou passives ; dans ce dernier cas, elles ne peuvent posséder de tâche en propre ni de programme principal. La façon de construire des partitions à partir du programme est laissée à la discrétion de l'implémentation, mais les contrôles à la compilation sont faits au niveau « programme » : on garantit donc la cohérence de toutes les partitions figurant dans une même bibliothèque.

La définition de la notion de partition est volontairement vague, afin de lui permettre de correspondre à différentes utilisations, que nous avons résumées dans le tableau de la figure 4.

Figure 4: Utilisations des partitions
Partition active Partition passive
Cartes autonomes Carte processeur Carte mémoire
Réseau local Ordinateur -
Système d'exploitation Processus DLL, mémoire partagée

Classification des paquetages

modifier

L'écriture d'un système distribué nécessite certaines précautions. Par exemple, si plusieurs partitions utilisent un paquetage « normal », celui-ci sera dupliqué dans chacune d'elles : s'il possède des états cachés, ils évolueront indépendamment dans chaque partition. Si l'on souhaite un autre comportement, on peut fournir des pragmas de catégorisation pour définir des sémantiques différentes qui apportent les restrictions (vérifiées par le compilateur) nécessaires au bon fonctionnement du système. On trouve ainsi :

  • Les paquetages « purs » (pure). Ils sont dupliqués dans chaque partition, mais les restrictions imposées garantissent que leur comportement est indépendant de leur duplication (pas d'état local).
  • Les paquetages « partagés passifs » (shared_passive). Ils ne peuvent contenir aucune entité active (ni servir à accéder à une entité active de façon indirecte) et sont présents dans une seule partition passive du système. Ils peuvent contenir des sous-programmes et des états rémanents, qui sont partagés par tous les utilisateurs du système.
  • Les paquetages « types distants » (remote_types). Ils servent à définir les types de données susceptibles d'être échangés entre partitions. Les restrictions associées garantissent que ces types ont les propriétés nécessaires pour permettre leur transmission sur un réseau (pas de pointeurs locaux par exemple, sauf si l'utilisateur a défini une procédure permettant la transmission).
  • Les paquetages « interface d'appel distant » (remote_call_interface, en abrégé RCI). La spécification de ces paquetages est dupliquée dans chaque partition, mais le corps n'est physiquement présent que dans une seule ; dans les autres, un corps de remplacement route les appels vers la partition qui possède le corps vrai.

Appels à distance

modifier

Lorsqu'un sous-programme est défini dans un paquetage « interface d'appel distant », tous les appels sont routés vers la partition sur laquelle il se trouve physiquement. De même, un pointeur sur un sous-programme défini dans un paquetage « interface d'appel distant » ou « types distants » est un type accès à distance (remote access type). Ceci signifie que le pointeur contient l'information nécessaire pour localiser la partition où se trouve physiquement le sous-programme. Tout appel à travers un tel pointeur sera également un appel distant. Les restrictions sur ces paquetages garantissent que les paramètres de tels sous-programmes sont toujours transmissibles sur le réseau.

De plus, lorsqu'un appel est transmis à travers le réseau, le compilateur doit utiliser pour le routage les services d'un paquetage prédéfini (System.RPC), dont le corps peut être fourni par l'utilisateur, le fournisseur du réseau, etc. Le compilateur est ainsi indépendant de la couche « transport », et il est possible d'implémenter la distribution par-dessus n'importe quelle couche réseau sans avoir à toucher au compilateur.

Cohérence d'un système réparti

modifier

Le problème de la cohérence d'un système réparti est délicat, car il doit répondre à deux exigences contradictoires :

  • d'une part, il faut garantir que toutes les partitions du système « voient » les données communes de la même façon ;
  • d'autre part, il faut pouvoir arrêter et relancer une partition, éventuellement pour corriger une erreur, sans être obligé d'arrêter tout le système.

Un programme Ada est dit « cohérent » si toutes les partitions qui le constituent utilisent la même version de toutes les unités de compilation. On autorise un programme Ada à être incohérent, c'est-à-dire qu'il est possible de corriger une erreur qui n'affecte qu'une seule partition, d'arrêter la partition et de la relancer avec la nouvelle version. Toutefois, si la correction implique un paquetage « partagé passif » ou « interface d'appel distant » (les seuls qui ne soient pas dupliqués), alors toute communication est coupée entre la nouvelle partition et le reste du système. On autorise donc un système localement incohérent, tant que cela ne sort pas de la partition en cause ; la cohérence est exigée pour les seuls éléments qui sont physiquement répartis sur le réseau.

Enfin, chaque unité de compilation possède un attribut 'Version (pour la spécification) et 'Body_Version (pour le corps), dont la valeur change à chaque modification. Il est donc possible de programmer des contrôles de cohérence plus fins si cela est nécessaire.

Le compilateur GNAT

modifier

Un des obstacles importants à la diffusion d'Ada a longtemps été le prix des compilateurs, notamment pour les institutions universitaires. Il est vrai que les fournisseurs ont consenti un important effort financier pour les organismes d'enseignement, mais le meilleur moyen de convaincre les gens d'utiliser Ada est de leur faire essayer le langage[1]. Or peu d'enseignants (aux crédits limités !) sont prêts à acheter un compilateur, même à prix réduit, juste pour voir...

Conscient de cet état de fait, le DoD a financé le développement d'un compilateur Ada 95 dont la diffusion est entièrement libre et gratuite. Il s'agit en fait d'un frontal du célèbre compilateur multi-langages GCC[2], faisant partie de l'environnement GNU de la Free Software Foundation, connu sous le nom de GNAT (GNU Ada Translator). La réalisation en a été confiée à la fameuse équipe de New York University, qui s'était déjà illustrée en réalisant le premier compilateur Ada (83) validé.

Comme tous les logiciels diffusés par la Free Software Foundation, GNAT est un logiciel libre, disponible non seulement sous forme exécutable, mais aussi sous forme de sources. Tous les enseignants des cours de compilation peuvent ainsi offrir à leurs élèves d'intervenir dans un compilateur Ada, c'est-à-dire dans ce qui se fait de mieux en matière de compilation.

GNAT est disponible par les canaux de diffusion habituels du GNU, en particulier on peut le charger par FTP anonyme depuis tous les bons serveurs de logiciels libres.

  1. Nous avons souvent constaté que les plus farouches opposants à Ada ne l'avaient jamais essayé...
  2. Pour Gnu Compiler Collection; le langage C n'est qu'un parmi les nombreux autres langages acceptés.

Exercices

modifier

Ces exercices s'adressent plus particulièrement aux anciens utilisateurs d'Ada 83.

  1. Etudier comment la notion de bibliothèque hiérarchique peut être utilisée pour réaliser la hiérarchisation des unités de la méthode HOOD (cf. La méthode HOOD)
  2. Reprendre les exemples classiques de boîte aux lettres ou de variable protégée qui utilisaient des tâches en Ada 83, et les récrire avec des types protégés.
  3. Le manuel de référence Ada 83 donne un exemple d'utilisation de l'instruction delay pour attendre jusqu'à une heure absolue. Expliquer pourquoi cet exemple est faux, ce qui a rendu nécessaire l'introduction de delay until.
  4. Comparer les fonctionnalités offertes par les nouveaux paquetages prédéfinis avec ceux de la bibliothèque standard d'autres langages, C notamment.