La programmation générique en Ada ── Écrire des contrats par les types, réaliser une réutilisation à coût nul

· Mis à jour le: · · Ada, Langage de Programmation, Génériques, Système de Types, Typage Statique, Modèle de Contrat, Abstraction à Coût Nul, GNAT, Alire, Haute Fiabilité, Réutilisation de Code

1. Introduction ── Pas « tout accepter », mais « ce que l’on promet »

Dès que l’on essaie d’écrire du code réutilisable dans un langage à typage statique, on se heurte rapidement au même problème. On voudrait qu’une pile écrite pour les entiers fonctionne aussi avec des chaînes de caractères. On voudrait appliquer le même traitement statistique à un tableau de flottants. On voudrait réutiliser la logique d’un tri croissant pour un tri décroissant. Mais si l’on copie le même code pour chaque type, des oublis de correction finissent par apparaître. À l’inverse, si l’on conçoit un système qui accepte n’importe quoi via void* ou des transtypages (casts), la sûreté de typage s’effondre.

La réponse d’Ada est la généricité (generic units).

Les génériques Ada ne sont pas une simple substitution de texte. Ils reçoivent des types, des valeurs, des sous-programmes, et même des paquets eux-mêmes comme paramètres formels, et sont vérifiés statiquement au moment de l’instanciation. Autrement dit, ce n’est pas au moment de l’exécution que l’on vérifie « ce type convient-il vraiment ? », mais à la compilation que l’on détermine « ce composant satisfait-il ce contrat ? ».

Traitement à réutiliserComment le réutiliser ?Copier-collervoid* / Object / transtypageGénériques AdaOublis de correction fréquentsErreurs à l'exécution ou pertes de sûreté de type fréquentesSûreté de typeVérification à la compilationAucune répartition dynamique superflue à l'exécution

Cet article organise la programmation générique en Ada selon le plan suivant :

  • Sous-programmes génériques
  • Paquets génériques
  • Paramètres de type, paramètres de valeur, paramètres de sous-programme
  • Catégories de types telles que private, range <>, digits <>
  • Exemples d’implémentation : tri, pile, traitement statistique, Count_If, magasin clé-valeur
  • Génériques d’ordre supérieur via les paramètres formels de paquet
  • Le contract model d’Ada et les principes de conception pratiques

Ce thème s’inscrit dans la continuité de notre série « L’attrait du langage Ada », « Introduction à la vérification formelle avec SPARK », « Une concurrence sûre » et « Systèmes temps réel ». Nous approfondissons ici la philosophie d’Ada consistant à « exprimer la conception par les types », sous l’angle de la généricité.

2. Plan de cet article

Commençons par saisir la vue d’ensemble à travers un schéma. Comprendre les génériques Ada comme une simple « fonctionnalité qui prend un type en argument » donne une vision bien trop étroite. En réalité, selon l’unité que l’on souhaite réutiliser, on combine sous-programmes, paquets, paramètres de sous-programme, paramètres de valeur et paramètres formels de paquet.

Génériques AdaSous-programme génériqueSwapCount_IfSortPaquet génériqueStackStatisticsKV StoreParamètres formelsTypeprivatelimited privaterange boxmod boxdigits boxdelta boxdiscrete boxObjetMax_SizeThresholdSous-programmeFonction LessFonction EqualsPredicatePaquetwith package P is newGenericIdées de conceptionModèle de contratVérification statiqueAbstraction à coût nulSéparation spécification etcorps

Ce qui compte dans la lecture de cet article est simple. La première moitié suit la syntaxe, la seconde traite des décisions de conception. Si vous découvrez Ada, n’essayez pas d’emblée de mémoriser les détails syntaxiques : concentrez-vous sur « ce qui est pris comme paramètre formel » et « quelles opérations ce paramètre formel autorise ».

3. Environnement d’exécution et méthode de compilation

Le code de cet article suppose GNAT 15.x ou une version ultérieure. GNAT est le compilateur Ada de référence, installable via Alire. Alire est le gestionnaire de paquets d’Ada / SPARK ; il sert aussi à gérer la chaîne d’outils et à construire les projets.

gnat --version
# GNAT 15.2.1

Installez GNAT depuis Alire (le gestionnaire de paquets d’Ada) avec alr install gnat_native gprbuild, puis ajoutez-le à votre PATH.

Les exemples traités dans cet article sont supposés placés ainsi dans le dépôt :

ada-generic-programming/src/snippets/01_swap.ada02_stack.ada03_sort.ada04_statistics.ada05_filter.ada06_kv_store.adaREADME.md

Les échantillons regroupant plusieurs unités de compilation dans un seul fichier doivent être découpés avec gnatchop avant d’être compilés avec gnatmake.

mkdir work
cd work
gnatchop ../src/snippets/01_swap.ada
gnatmake -gnata swap_demo
./swap_demo

-gnata est une option qui active les assertions. Elle n’est pas indispensable pour utiliser la généricité elle-même, mais elle facilite la vérification des contrats et des conditions limites dans les exemples pédagogiques.

ExécutablegnatmakegnatchopDéveloppeurExécutablegnatmakegnatchopDéveloppeurFournit un seul fichier .adaDécoupe en .ads / .adb / maingnatmake -gnata mainExécute la liaison (bind) et l'édition de liens./mainRésultat d'exécution

4. Le modèle de base des génériques Ada

Les génériques Ada se comprennent globalement en trois étapes :

  1. Écrire une unité générique
  2. Écrire les paramètres formels dans la partie generic
  3. Instancier avec new du côté utilisateur
Déclaration genericParamètres formelsCorps génériqueInstanciation via newUtilisé comme sous-programme ou paquet normalTypeValeurSous-programmePaquet

Par exemple, si l’on rend générique le traitement qui échange deux valeurs, seul le type peut être pris comme paramètre formel.

generic
   type Element is private;
procedure Generic_Swap (A, B : in out Element);

À ce stade, Generic_Swap ne peut pas encore être appelé. C’est un « modèle d’échange utilisable pour n’importe quel type Element ». Il ne devient une procédure normale qu’une fois qu’on lui fournit un type concret.

procedure Swap_Integer is new Generic_Swap (Integer);

En schéma, la relation est la suivante :

Passer IntegerPasser CharacterPasser My_RecordGeneric_Swaptype Element is privateSwap_IntegerSwap_CharacterSwap_My_RecordÉchange des variables IntegerÉchange des variables CharacterÉchange des variables My_Record

Ce qui compte, c’est que le corps du modèle n’est écrit qu’avec les opérations utilisables sur Element. Si l’on déclare type Element is private;, les opérations de base comme l’affectation ou la comparaison d’égalité sont utilisables, mais pas la comparaison d’ordre ni les opérations arithmétiques. Autrement dit, la déclaration générique elle-même exprime « ce que ce composant a le droit de présumer ».

5. Les types de paramètres formels ── le vocabulaire des génériques Ada

Ce que les génériques Ada peuvent recevoir ne se limite pas aux types. C’est là un point qui les distingue nettement des génériques classiques de C# ou de Java.

Paramètres formels génériquesParamètres de typeObjet / paramètres de valeurParamètres de sous-programmeParamètres de paquettype Element is privatetype Index is boxtype Real is digits boxMax_Size : PositiveDefault_Value : Elementwith function Less...with procedure Put ...with package P is new ...

En tableau, les paramètres formels typiques se présentent ainsi :

Type Exemple Signification
Paramètre de type type Element is private; Forme de base : reçoit tout type definite non limited
Paramètre de type limited type Element is limited private; Reçoit aussi les types non copiables
Type discret type Index is (<>); Type entier ou énuméré, utilisable comme indice de tableau
Type entier signé type Count is range <>; Présume +, -, la comparaison d’ordre et d’autres opérations entières
Type entier modulaire type Word is mod <>; Traite les opérations bit à bit et l’arithmétique modulaire
Type flottant type Real is digits <>; Float, Long_Float, type flottant défini par l’utilisateur, etc.
Type virgule fixe type Money is delta <>; Traite l’arithmétique en virgule fixe
Paramètre de valeur Max_Size : Positive; Fixe une taille ou un seuil pour chaque instance
Sous-programme with function Predicate (...) return Boolean; Injecte un comportement tel qu’une fonction de comparaison ou un prédicat
Paquet with package P is new Some_Generic (<>); Reçoit comme composant une instance déjà créée d’un paquet générique

Grâce à ce vocabulaire, Ada permet naturellement d’écrire « on ne reçoit que les types capables de ces opérations », plutôt que « on reçoit n’importe quoi, quitte à faire des choses dangereuses à l’intérieur ».

6. Sous-programmes génériques ── comprendre la structure minimale avec Generic_Swap

Comme premier exemple, examinons Generic_Swap, qui échange deux variables d’un type quelconque.

6.1 Spécification

generic
   type Element is private;
procedure Generic_Swap (A, B : in out Element);

La partie qui suit generic constitue les paramètres formels. Ici, on reçoit un type nommé Element. is private signifie que, du point de vue du corps générique, on ne connaît pas la représentation interne de ce type.

Cette déclaration nous apprend deux choses :

  • Generic_Swap fonctionne pour n’importe quel type Element
  • Le corps ne dépend ni de la structure interne d’Element, ni de sa comparaison d’ordre

6.2 Corps

procedure Generic_Swap (A, B : in out Element) is
   Temp : constant Element := A;
begin
   A := B;
   B := Temp;
end Generic_Swap;

Ce corps n’utilise que l’affectation sur Element. Ni A < B ni A + B n’apparaissent. Il fonctionne donc naturellement pour Integer, Character, un type enregistrement, un type énuméré — tout type affectable.

Après l'appelAvant l'appelA = 20B = 10A = 10B = 20Temp = A

6.3 Instanciation

Côté utilisateur, on utilise new.

procedure Swap_Int  is new Generic_Swap (Integer);
procedure Swap_Char is new Generic_Swap (Character);

Swap_Int et Swap_Char deviennent alors des procédures normales, appelables directement.

with Ada.Text_IO; use Ada.Text_IO;

procedure Swap_Demo is
   generic
      type Element is private;
   procedure Generic_Swap (A, B : in out Element);

   procedure Generic_Swap (A, B : in out Element) is
      Temp : constant Element := A;
   begin
      A := B;
      B := Temp;
   end Generic_Swap;

   procedure Swap_Int is new Generic_Swap (Integer);

   X : Integer := 10;
   Y : Integer := 20;
begin
   Put_Line ("Before: X=" & Integer'Image (X) & ", Y=" & Integer'Image (Y));
   Swap_Int (X, Y);
   Put_Line ("After : X=" & Integer'Image (X) & ", Y=" & Integer'Image (Y));
end Swap_Demo;

L’exécution donne le résultat suivant :

Before: X= 10, Y= 20
After : X= 20, Y= 10

On ne peut pas passer de variable Float à Swap_Int (X, Y); : Swap_Int est une procédure normale instanciée spécifiquement pour Integer. Il est plus facile de comprendre la généricité non pas comme « un trou où tout peut entrer », mais comme « un mécanisme qui fabrique, pour chaque type, un artefact concret et sûr ».

7. Paquets génériques ── paramétrer types et valeurs

Lorsqu’on veut réutiliser non pas un seul sous-programme mais un ensemble d’opérations avec un état interne, on utilise un paquet générique. L’exemple classique est la pile.

Pour une pile, tant que seuls le type d’élément et la taille maximale changent, la logique de base reste la même.

Generic_StackElement_TypeMax_SizePush / Pop / Size / Is_Empty / Is_FullInt_StackElement=IntegerMax_Size=5Float_StackElement=FloatMax_Size=3String_StackElement=Unbounded_StringMax_Size=20

7.1 Spécification

generic
   type Element_Type is private;
   Max_Size : Positive;
package Generic_Stack is
   procedure Push (Item : Element_Type);
   function Pop return Element_Type;
   function Is_Empty return Boolean;
   function Is_Full  return Boolean;
   function Size return Natural;

   Stack_Overflow  : exception;
   Stack_Underflow : exception;
end Generic_Stack;

Ici, deux types de paramètres formels sont utilisés.

  • Element_Type est un paramètre de type
  • Max_Size est un paramètre de valeur

Max_Size étant de type Positive, on ne peut pas instancier avec une taille inférieure ou égale à 0. Un paramètre de valeur peut ainsi porter, lui aussi, une contrainte de type.

7.2 Corps

package body Generic_Stack is
   subtype Index_Type is Positive range 1 .. Max_Size;
   type Storage_Type is array (Index_Type) of Element_Type;

   Data : Storage_Type;
   Top  : Natural := 0;

   procedure Push (Item : Element_Type) is
   begin
      if Top = Max_Size then
         raise Stack_Overflow;
      end if;

      Top := Top + 1;
      Data (Top) := Item;
   end Push;

   function Pop return Element_Type is
      Result : Element_Type;
   begin
      if Top = 0 then
         raise Stack_Underflow;
      end if;

      Result := Data (Top);
      Top := Top - 1;
      return Result;
   end Pop;

   function Is_Empty return Boolean is
   begin
      return Top = 0;
   end Is_Empty;

   function Is_Full return Boolean is
   begin
      return Top = Max_Size;
   end Is_Full;

   function Size return Natural is
   begin
      return Top;
   end Size;
end Generic_Stack;

Ce qui compte dans ce corps de paquet, c’est que Data et Top sont créés séparément pour chaque instance.

package Int_Stack   is new Generic_Stack (Integer, 5);
package Float_Stack is new Generic_Stack (Float,   3);

Ces deux paquets sont créés à partir du même modèle, mais ne partagent pas leur état interne.

État de Float_StackTopData : tableau FloatÉtat de Int_StackTopData : tableau IntegerGeneric_StackInt_StackFloat_Stack

7.3 Transitions d’état de la pile

Il est plus facile de comprendre la pile en la voyant comme une machine à états.

PushPush / PopPop retire le dernier élémentPush atteint Max_SizePopPushPopEmptyNonEmptyFullOverflowUnderflow

7.4 Exemple d’utilisation

with Ada.Text_IO; use Ada.Text_IO;

procedure Stack_Demo is
   generic
      type Element_Type is private;
      Max_Size : Positive;
   package Generic_Stack is
      procedure Push (Item : Element_Type);
      function Pop return Element_Type;
      function Is_Empty return Boolean;
      function Is_Full  return Boolean;
      function Size return Natural;
      Stack_Overflow  : exception;
      Stack_Underflow : exception;
   end Generic_Stack;

   package body Generic_Stack is
      subtype Index_Type is Positive range 1 .. Max_Size;
      type Storage_Type is array (Index_Type) of Element_Type;

      Data : Storage_Type;
      Top  : Natural := 0;

      procedure Push (Item : Element_Type) is
      begin
         if Top = Max_Size then
            raise Stack_Overflow;
         end if;

         Top := Top + 1;
         Data (Top) := Item;
      end Push;

      function Pop return Element_Type is
         Result : Element_Type;
      begin
         if Top = 0 then
            raise Stack_Underflow;
         end if;

         Result := Data (Top);
         Top := Top - 1;
         return Result;
      end Pop;

      function Is_Empty return Boolean is (Top = 0);
      function Is_Full  return Boolean is (Top = Max_Size);
      function Size     return Natural is (Top);
   end Generic_Stack;

   package Int_Stack is new Generic_Stack (Integer, 5);
begin
   Int_Stack.Push (10);
   Int_Stack.Push (20);
   Int_Stack.Push (30);

   Put_Line ("Size=" & Natural'Image (Int_Stack.Size));
   Put_Line ("Pop =" & Integer'Image (Int_Stack.Pop));
   Put_Line ("Pop =" & Integer'Image (Int_Stack.Pop));
   Put_Line ("Size=" & Natural'Image (Int_Stack.Size));
end Stack_Demo;

En pratique, les paquets génériques sont particulièrement efficaces pour les « petits conteneurs », les « tampons de taille fixe », les « tampons circulaires (ring buffers) », les « files pour la journalisation », ou les « couches d’abstraction matérielle ». En Ada en particulier, fixer statiquement la taille via des paramètres de type et de valeur, plutôt que de la faire varier à l’exécution, s’accorde bien avec la conception de systèmes à haute fiabilité.

8. Paramètres formels de sous-programme ── injecter du comportement

Recevoir uniquement un type ne suffit pas toujours à tout exprimer. Pour un tri, par exemple, il ne faut pas seulement le type d’élément, mais aussi la logique de comparaison qui détermine « lequel placer en premier ».

En Ada, cette fonction de comparaison peut elle-même devenir un paramètre formel du générique.

Generic_Insertion_SortItem_TypeIndexItem_ArrayFonction de comparaisonUtilise la comparaison standardPasse Greater pour un tri décroissantPasse un ordre personnalisé

8.1 Spécification

generic
   type Item_Type is private;
   type Index is (<>);
   type Item_Array is array (Index range <>) of Item_Type;
   with function "<" (Left, Right : Item_Type) return Boolean is <>;
procedure Generic_Insertion_Sort (Items : in out Item_Array);

Il y a ici quatre paramètres formels.

  1. Item_Type : le type des éléments du tableau
  2. Index : le type des indices du tableau
  3. Item_Array : le type de tableau réel
  4. "<" : la fonction de comparaison

type Index is (<>); reçoit un type discret. Cela inclut non seulement les types entiers, mais aussi les types énumérés. Pouvoir utiliser, en plus de Positive, un type énuméré comme Day pour l’indice de tableau, voilà ce qui est typiquement Ada.

Dans with function "<" ... is <>;, le is <> signifie que si le paramètre effectif est omis, on utilise l’opérateur standard visible ou une fonction correspondante. Autrement dit, pour un type qui possède déjà un <, comme Integer, la fonction de comparaison est utilisable sans être explicitée.

8.2 Corps

procedure Generic_Insertion_Sort (Items : in out Item_Array) is
   J   : Index;
   Key : Item_Type;
begin
   if Items'Length <= 1 then
      return;
   end if;

   for I in Index'Succ (Items'First) .. Items'Last loop
      Key := Items (I);
      J := I;

      while J > Items'First and then Key < Items (Index'Pred (J)) loop
         Items (J) := Items (Index'Pred (J));
         J := Index'Pred (J);
      end loop;

      Items (J) := Key;
   end loop;
end Generic_Insertion_Sort;

Le tri par insertion ne convient pas aux grands tableaux, mais il se prête bien à l’explication de la généricité. En ne changeant que la fonction de comparaison, la même structure de boucle peut servir aussi bien pour un tri croissant que décroissant.

OuiNonNonOuiTableau non triéExtraire Key en partant de la gaucheKey précède-t-il l'élément précédent ?Décaler l'élément précédent vers la droiteInsérer KeyTraitement terminé ?Tableau trié

8.3 Créer un ordre croissant et décroissant à partir du même corps

type Int_Array is array (Positive range <>) of Integer;

procedure Sort_Asc is new Generic_Insertion_Sort
  (Item_Type  => Integer,
   Index      => Positive,
   Item_Array => Int_Array);

function Greater (Left, Right : Integer) return Boolean is
  (Left > Right);

procedure Sort_Desc is new Generic_Insertion_Sort
  (Item_Type  => Integer,
   Index      => Positive,
   Item_Array => Int_Array,
   "<"        => Greater);

Sort_Asc utilise le < standard. Sort_Desc, lui, substitue la fonction de comparaison en écrivant "<" => Greater.

99, 3, 47, 12Sort_AscComparaison standardSort_DescGreater passé comme comparaison3, 12, 47, 9999, 47, 12, 3

Ce mécanisme se rapproche de la conception qui consiste, en C++, à passer un objet fonction de comparaison en argument de template, ou, en Rust, à exiger un ordre via une borne de trait. En Ada toutefois, on explicite clairement, en tant que paramètre formel de sous-programme, « quelle forme de fonction on passe ».

9. Catégories de types ── écrire des contrats plus précis que private

type T is private; est pratique, mais ne permet pas tout. Pour un type private, on ne peut pas naturellement utiliser les opérations arithmétiques ou la comparaison d’ordre. C’est pourquoi Ada permet de spécifier une catégorie sur le paramètre de type formel.

Type formelprivatelimited privatediscrete box : type discretrange box : entier signémod box : entier modulairedigits box : virgule flottantedelta box : virgule fixetype accessType énuméréType entierFloatLong_FloatType flottant défini par l'utilisateur

9.1 Pourquoi spécifier une catégorie apporte-t-il un bénéfice ?

Calculer une moyenne ou une variance requiert l’addition, la soustraction, la multiplication et la division. Un type private ne permet pas de présumer ces opérations. On restreint donc le paramètre à un type flottant.

generic
   type Real is digits <>;
   type Real_Array is array (Positive range <>) of Real;
package Generic_Statistics is
   function Mean (Values : Real_Array) return Real;
   function Variance (Values : Real_Array) return Real;
end Generic_Statistics;

Grâce à type Real is digits <>;, on sait que Real est un type flottant. Le corps générique peut donc utiliser +, -, *, /.

9.2 Corps

package body Generic_Statistics is
   function Mean (Values : Real_Array) return Real is
      Sum : Real := 0.0;
   begin
      if Values'Length = 0 then
         return 0.0;
      end if;

      for V of Values loop
         Sum := Sum + V;
      end loop;

      return Sum / Real (Values'Length);
   end Mean;

   function Variance (Values : Real_Array) return Real is
      M   : constant Real := Mean (Values);
      Sum : Real := 0.0;
   begin
      if Values'Length = 0 then
         return 0.0;
      end if;

      for V of Values loop
         declare
            D : constant Real := V - M;
         begin
            Sum := Sum + D * D;
         end;
      end loop;

      return Sum / Real (Values'Length);
   end Variance;
end Generic_Statistics;

9.3 Utilisation avec Float et Long_Float

type Float_Array is array (Positive range <>) of Float;
type Long_Array  is array (Positive range <>) of Long_Float;

package Float_Stats is new Generic_Statistics (Float, Float_Array);
package Long_Stats  is new Generic_Statistics (Long_Float, Long_Array);

Le même traitement statistique peut être réutilisé pour des types flottants de précisions différentes.

Generic_StatisticsReal is digits boxFloat_StatsLong_StatsMy_Real_StatsMean / Variance avec FloatMean / Variance avec Long_FloatMean / Variance avec un Real défini par l'utilisateur

9.4 La spécification de catégorie est un « cahier des charges au niveau du type »

La spécification de catégorie n’est pas qu’une simple syntaxe destinée à satisfaire le compilateur. Elle constitue aussi, pour le lecteur, un cahier des charges indiquant « ce que ce composant exige ».

Traitement souhaité Type formel adapté Raison
Échanger, stocker, récupérer private L’affectation suffit
Gérer une ressource non copiable limited private Ne présume pas l’affectation
Indexer un tableau, parcourir des états énumérés (<>) First, Last, Succ, Pred sont utilisables
Sommer des entiers, compter range <> Présume l’arithmétique entière
Masque de bits, compteur cyclique mod <> Présume l’arithmétique modulaire
Moyenne, variance, calcul numérique digits <> Présume l’arithmétique flottante
Montants, grandeurs de contrôle à précision fixe delta <> Présume l’arithmétique en virgule fixe

10. Injection de prédicats ── écrire Count_If à la manière Ada

Les paramètres formels de sous-programme ne servent pas qu’aux fonctions de comparaison, ils servent aussi aux prédicats. Un prédicat est une fonction qui reçoit une valeur et retourne un Boolean.

Le rôle que jouent Func<T, bool> en C#, Predicate<T> en Java, ou les lambdas et objets fonction en C++, Ada peut l’exprimer par un sous-programme formel de générique.

10.1 Spécification

generic
   type Element is private;
   type Index is (<>);
   type Array_Type is array (Index range <>) of Element;
   with function Predicate (Item : Element) return Boolean;
function Generic_Count_If (Arr : Array_Type) return Natural;

Ici, is <> n’est pas ajouté à Predicate. Comme il n’existe pas de fonction de prédicat standard visible par défaut, l’appelant doit obligatoirement en fournir une.

10.2 Corps

function Generic_Count_If (Arr : Array_Type) return Natural is
   Count : Natural := 0;
begin
   for Item of Arr loop
      if Predicate (Item) then
         Count := Count + 1;
      end if;
   end loop;

   return Count;
end Generic_Count_If;

Le déroulement du traitement est simple.

VraiFauxTableauParcourt chaque élémentPredicate(Item) ?Incrémente CountNe fait rienÉlément suivantRetourne Count

10.3 Compter les nombres pairs et compter au-delà d’un seuil

type Int_Array is array (Positive range <>) of Integer;

function Is_Even (N : Integer) return Boolean is
  (N mod 2 = 0);

function Is_Large (N : Integer) return Boolean is
  (N > 50);

function Count_Even is new Generic_Count_If
  (Element    => Integer,
   Index      => Positive,
   Array_Type => Int_Array,
   Predicate  => Is_Even);

function Count_Large is new Generic_Count_If
  (Element    => Integer,
   Index      => Positive,
   Array_Type => Int_Array,
   Predicate  => Is_Large);

À partir de la même logique de parcours, on peut créer deux fonctions qui ne diffèrent que par leur condition.

Generic_Count_IfCount_EvenPredicate = Is_EvenCount_LargePredicate = Is_Large12, 7, 88, 3, 56, 91, 44, 19, 62Nombre de valeurs pairesNombre de valeurs supérieures à 50

Dans cet exemple, le parcours du tableau, la gestion du compteur et le retour du résultat sont entièrement mutualisés. Seul « ce que l’on compte » est injecté comme fonction. C’est la forme fondamentale de conception d’ordre supérieur en Ada.

11. Composer plusieurs paramètres ── un magasin clé-valeur générique

Dans un composant réel, il est rare de se contenter d’un seul paramètre de type. Le type de la clé, le type de la valeur, la méthode de comparaison des clés, le nombre maximal d’entrées : il faut souvent combiner plusieurs conditions.

Prenons ici l’exemple d’un magasin clé-valeur simple, de taille fixe.

Generic_KV_StoreKey_TypeValue_TypeFonction de correspondance de cléMax_EntriesPut / Get / ContainsMagasin de valeurs de configurationPetit cacheDictionnaire de taille fixe pour l'embarqué

11.1 Spécification

generic
   type Key_Type is private;
   type Value_Type is private;
   with function "=" (Left, Right : Key_Type) return Boolean is <>;
   Max_Entries : Positive := 50;
package Generic_KV_Store is
   procedure Put (Key : Key_Type; Val : Value_Type);
   function Get (Key : Key_Type) return Value_Type;
   function Contains (Key : Key_Type) return Boolean;

   Key_Not_Found : exception;
   Store_Full    : exception;
end Generic_KV_Store;

Ce paquet comporte quatre paramètres formels.

Paramètre Type Rôle
Key_Type Type Le type de la clé
Value_Type Type Le type de la valeur
"=" Sous-programme Le test d’égalité des clés
Max_Entries Valeur Le nombre maximal d’entrées

Max_Entries reçoit une valeur par défaut avec := 50. Sans indication particulière, le magasin comportera donc 50 entrées.

11.2 Corps

package body Generic_KV_Store is
   subtype Index_Type is Positive range 1 .. Max_Entries;

   type Key_Array   is array (Index_Type) of Key_Type;
   type Value_Array is array (Index_Type) of Value_Type;
   type Used_Array  is array (Index_Type) of Boolean;

   Keys   : Key_Array;
   Values : Value_Array;
   Used   : Used_Array := (others => False);

   function Find_Index (Key : Key_Type) return Natural is
   begin
      for I in Index_Type loop
         if Used (I) and then Keys (I) = Key then
            return I;
         end if;
      end loop;

      return 0;
   end Find_Index;

   function Find_Free return Natural is
   begin
      for I in Index_Type loop
         if not Used (I) then
            return I;
         end if;
      end loop;

      return 0;
   end Find_Free;

   procedure Put (Key : Key_Type; Val : Value_Type) is
      Pos : Natural := Find_Index (Key);
   begin
      if Pos = 0 then
         Pos := Find_Free;

         if Pos = 0 then
            raise Store_Full;
         end if;

         Used (Pos) := True;
         Keys (Pos) := Key;
      end if;

      Values (Pos) := Val;
   end Put;

   function Get (Key : Key_Type) return Value_Type is
      Pos : constant Natural := Find_Index (Key);
   begin
      if Pos = 0 then
         raise Key_Not_Found;
      end if;

      return Values (Pos);
   end Get;

   function Contains (Key : Key_Type) return Boolean is
   begin
      return Find_Index (Key) /= 0;
   end Contains;
end Generic_KV_Store;

Cette implémentation repose sur une recherche linéaire, elle n’est donc pas adaptée aux gros volumes de données. Mais elle reste facile à employer dans les cas où la propriété importante est d’être de taille fixe, de petite échelle, et sans allocation dynamique de mémoire.

Keys/Values/UsedInstance de Generic_KV_StoreAppelantKeys/Values/UsedInstance de Generic_KV_StoreAppelantalt[Clé existante][Nouvelle clé]Put(Key, Value)Find_Index(Key)Values(Pos) := ValueFind_FreeKeys(Pos) := KeyValues(Pos) := ValueUsed(Pos) := TrueGet(Key)Find_Index(Key)PosValues(Pos)

11.3 Exemple d’instanciation

with Ada.Strings.Unbounded;
use Ada.Strings.Unbounded;

procedure KV_Demo is
   package Int_String_Store is new Generic_KV_Store
     (Key_Type    => Integer,
      Value_Type  => Unbounded_String,
      Max_Entries => 10);
begin
   Int_String_Store.Put (1, To_Unbounded_String ("Ada"));
   Int_String_Store.Put (2, To_Unbounded_String ("SPARK"));

   if Int_String_Store.Contains (1) then
      -- Get (1) permet de récupérer la valeur
      null;
   end if;
end KV_Demo;

Le "=" a été omis. Integer possède un opérateur d’égalité standard, et c’est celui-ci que is <> utilise.

Si les clés étaient, par exemple, des chaînes de caractères insensibles à la casse, on pourrait passer une fonction d’égalité personnalisée.

function Same_Key (Left, Right : Unbounded_String) return Boolean is
  (To_Lower (To_String (Left)) = To_Lower (To_String (Right)));

package String_Key_Store is new Generic_KV_Store
  (Key_Type    => Unbounded_String,
   Value_Type  => Integer,
   "="         => Same_Key,
   Max_Entries => 100);

12. Paramètres formels de paquet ── composer les génériques entre eux

Dans les génériques Ada, un paquet lui-même peut devenir un paramètre formel. Cela permet de traiter « une instance créée à partir d’un paquet générique » comme l’entrée d’un autre générique.

Generic_StackInt_StackGeneric_Stack_LoggerInstance de Int_Stack_Logger

12.1 Un journal (logger) qui reçoit une pile

Supposons, par exemple, que l’on veuille créer un journal (logger) qui reçoit une instance du Generic_Stack vu précédemment et affiche sa taille.

generic
   with package Stack is new Generic_Stack (<>);
package Generic_Stack_Logger is
   procedure Print_Size;
end Generic_Stack_Logger;

Le corps se présente ainsi :

with Ada.Text_IO; use Ada.Text_IO;

package body Generic_Stack_Logger is
   procedure Print_Size is
   begin
      Put_Line ("Stack size =" & Natural'Image (Stack.Size));
   end Print_Size;
end Generic_Stack_Logger;

Côté utilisateur, on crée d’abord la pile, puis on la passe au journal.

package Int_Stack is new Generic_Stack
  (Element_Type => Integer,
   Max_Size     => 10);

package Int_Stack_Logger is new Generic_Stack_Logger
  (Stack => Int_Stack);

Une telle conception permet de combiner des composants génériques entre eux.

Deuxième étapePremière étapeInt_Stack_LoggerGeneric_Stack_LoggerInt_StackGeneric_StackPrint_Size

Cet usage se rapproche des paramètres de template de template en C++, mais en Ada, on peut expliciter clairement que l’on reçoit « une instance de ce paquet générique ». Dans les grandes bases de code Ada, c’est pratique pour séparer et combiner conteneurs, algorithmes, journalisation, vérification, ou auxiliaires de test.

13. Le contract model ── l’idée la plus importante des génériques Ada

Ce qui importe le plus pour comprendre les génériques Ada, c’est le contract model.

Le corps générique doit être écrit en n’utilisant que les opérations promises par les paramètres formels. Par exemple, si l’on n’a déclaré que type Element is private;, on ne peut pas utiliser < sur Element. Pour utiliser <, il faut soit l’expliciter comme sous-programme formel, soit préciser davantage la catégorie du type.

Partie formelle generic= contratCorps genericimplémenté dans les limites du contratCorps vérifié seul, indépendammentInstanciationParamètres effectifstypes, fonctions, valeurs réelsVérifie que les paramètres effectifs satisfont le contratPaquet ou sous-programme normal

Cette conception protège aussi bien l’utilisateur du générique que celui qui l’écrit.

13.1 Différence de perspective avec les templates C++

Les templates C++ sont puissants, mais ils ont historiquement la particularité de ne révéler leurs erreurs qu’une fois le corps de template instancié. Les concepts de C++20 ont amélioré ce point, mais les génériques Ada adoptent, dès l’origine, un modèle où le contrat est explicite.

Templates C++Les expressions requises se concrétisent à l'instanciationLe corps du template est écritLes concepts permettent d'expliciter les contraintesAdaLe corps est vérifié dans les limites du contratLe contrat est écrit dans la partie formelleL'instanciation vérifie le paramètre effectif

Les génériques de Java et de C# placent au centre de leur conception les types référence, les contraintes, l’effacement de type et la relation avec la représentation à l’exécution. Les génériques Ada, eux, penchent vers l’idée de créer des instances concrètes à la compilation.

Aspect Ada C++ Java Rust
Manière d’écrire le contrat Types, valeurs, fonctions, paquets dans la partie formelle Templates / concepts Paramètres de type et bounds Trait bounds
Vérification du corps Vérifiée dans les limites du contrat des paramètres formels Principalement lors de la concrétisation à l’instanciation Vérifiée dans les limites des bounds Vérifiée dans les limites des trait bounds
Coût à l’exécution Résolution statique par défaut Génération statique par défaut Subit l’effet de l’effacement de type Monomorphisation par défaut
Paramètres de valeur Oui Oui Limité Const generics
Sous-programme comme paramètre formel Oui Via des objets fonction, etc. Lambda / interface fonctionnelle Closure / fonction / trait
Paquet comme paramètre formel Oui Template de template, etc. Non Distinct de la structure de module

Les mécanismes détaillés diffèrent d’un langage à l’autre, mais la caractéristique propre à Ada est d’« écrire le contrat comme une syntaxe, en premier ».

14. Décisions de conception en pratique ── que doit-on rendre générique ?

La généricité est pratique, mais tout ne doit pas nécessairement devenir générique. En pratique, le raisonnement suivant limite les mauvaises décisions.

OuiNonOuiNonOuiNonOuiNonIl existe un traitement à réutiliserSeul le type diffère ?Envisager un paramètre de typeLa taille ou le seuil diffèrent aussi ?Ajouter un paramètre de valeurLe comportement de comparaison ou de décision diffère ?Ajouter un sous-programme formelSouhaite-t-on regrouper l'état interne et l'API ?Paquet génériqueUn sous-programme normal suffit

14.1 Cas où un sous-programme générique convient

Les sous-programmes génériques conviennent aux algorithmes sans état.

  • Swap
  • Sort
  • Count_If
  • Find
  • Des transformations de type Map
  • Min / Max

Lorsque le corps de l’algorithme est court et que l’entrée et la sortie sont claires, un sous-programme se lit plus facilement qu’un paquet.

14.2 Cas où un paquet générique convient

Les paquets génériques conviennent lorsqu’on souhaite regrouper, avec le type, plusieurs opérations ainsi qu’un état interne.

  • Pile de taille fixe
  • Tampon circulaire (ring buffer)
  • Petit dictionnaire
  • Ensemble de traitements statistiques
  • Abstraction d’E/S propre à chaque périphérique
  • Ensemble d’opérations pour des types numériques porteurs d’unité

En Ada en particulier, la spécification du paquet constitue l’API publique et le corps du paquet l’implémentation ; cette séparation fait qu’un paquet générique peut servir de « modèle de module type-sûr ».

masquéSpécification du paquetAPI publiqueCôté utilisateurCorps du paquetimplémentation internePartie formelle genericcontrat de types, valeurs, fonctions

14.3 Commencer avec peu de paramètres formels

Si l’on multiplie trop les paramètres formels, l’instanciation devient difficile à lire. Il est plus sûr de commencer avec un minimum, et de n’en ajouter que lorsqu’un besoin concret de substitution apparaît.

-- Exemple difficile à lire
package X is new Generic_Foo
  (A, B, C, D, E, F, G);

-- L'association nommée préserve l'intention
package X is new Generic_Foo
  (Element_Type => Integer,
   Index_Type   => Positive,
   Buffer_Size  => 128,
   "<"          => Less);

En Ada, on peut utiliser l’association nommée au moment de l’instanciation. Comme les points importants de la conception apparaissent précisément à l’instanciation d’un générique, écrire avec des noms explicites facilite souvent la maintenance du code en pratique.

15. Pièges fréquents

Les génériques Ada sont puissants, mais présentent des points sur lesquels on trébuche facilement au début.

15.1 Impossible de comparer un type private

Le corps suivant ne peut pas être écrit.

generic
   type Element is private;
function Bad_Min (A, B : Element) return Element;

function Bad_Min (A, B : Element) return Element is
begin
   if A < B then      -- Erreur ici
      return A;
   else
      return B;
   end if;
end Bad_Min;

Element n’est déclaré que comme private, rien ne garantit donc que < soit utilisable. Pour comparer, il faut l’ajouter au contrat, comme ceci :

generic
   type Element is private;
   with function "<" (Left, Right : Element) return Boolean is <>;
function Generic_Min (A, B : Element) return Element;
On veut comparer dans le corpsÉcrire une fonction de comparaison dans la partie formelleLa possibilité de comparaison est vérifiée à l'instanciationprivate seulErreur de compilation dans le corps générique

15.2 is <> n’est pas une « inférence automatique universelle »

is <> est pratique, mais ce n’est pas de la magie. Au point d’instanciation, il faut qu’un opérateur ou un sous-programme correspondant soit visible. Si l’on a placé sa propre fonction de comparaison dans un autre paquet, il faut faire un with et un use appropriés, ou bien la passer explicitement par son nom, ce qui est plus sûr.

procedure Sort_By_Age is new Generic_Insertion_Sort
  (Item_Type  => Person,
   Index      => Positive,
   Item_Array => Person_Array,
   "<"        => Younger_Than);

15.3 Les exceptions aussi deviennent distinctes par instance

Si l’on déclare une exception dans la spécification d’un paquet générique, elle devient une exception différente pour chaque instance.

package Int_Stack   is new Generic_Stack (Integer, 5);
package Float_Stack is new Generic_Stack (Float, 3);

Dans ce cas, Int_Stack.Stack_Overflow et Float_Stack.Stack_Overflow sont traitées comme deux exceptions distinctes. Si l’on souhaite une exception commune, il faut envisager de la définir en dehors du générique.

exception distincteGeneric_Stackdéclare Stack_OverflowInt_Stack.Stack_OverflowFloat_Stack.Stack_Overflow

15.4 La taille du code peut augmenter

La généricité permet d’éviter facilement les indirections superflues à l’exécution, mais comme une instance est créée pour chaque type, un grand nombre d’instances peut augmenter la taille du code.

C’est un compromis que l’on retrouve aussi bien dans les templates C++ que dans la monomorphisation de Rust. Dans les développements orientés haute fiabilité, embarqué ou temps réel, l’approche consiste à réduire l’incertitude à l’exécution en échange d’une gestion de la taille des artefacts produits à la compilation.

Un seul corps genericVersion IntegerVersion FloatVersion Long_FloatVersion My_TypeCode généréFacilite l'évitement des vérifications de type et du boxing à l'exécutionAttention à l'augmentation de taille si les instances sont nombreuses

15.5 Quand utiliser limited private

type Element is private; présuppose l’affectation. Pour des poignées de fichier, des verrous, des poignées de périphérique — des ressources que l’on ne veut pas copier — envisagez limited private.

generic
   type Resource is limited private;
   with procedure Close (R : in out Resource);
procedure Generic_Use_And_Close (R : in out Resource);

Pour concevoir avec des types non copiables, un algorithme qui applique une procédure ou qui explicite une référence est plus sûr qu’un conteneur qui stocke des valeurs.

16. Petit recueil de patrons de conception

Voici, résumés brièvement, quelques patrons souvent utiles en pratique.

16.1 Ne fournir Min que pour des valeurs comparables

generic
   type Element is private;
   with function "<" (Left, Right : Element) return Boolean is <>;
function Generic_Min (A, B : Element) return Element;

function Generic_Min (A, B : Element) return Element is
begin
   if A < B then
      return A;
   else
      return B;
   end if;
end Generic_Min;
ElementNécessite une fonction de comparaisonGeneric_MinRetourne le plus petit

16.2 Faire du seuil un paramètre de valeur

generic
   type Count_Type is range <>;
   Threshold : Count_Type;
function Generic_Is_Over (Value : Count_Type) return Boolean;

function Generic_Is_Over (Value : Count_Type) return Boolean is
begin
   return Value > Threshold;
end Generic_Is_Over;

Un paramètre de valeur convient à une grandeur que l’on souhaite fixer comme propriété de l’instance, plutôt que comme réglage variable à l’exécution.

16.3 Injecter un moyen de sortie

generic
   type Element is private;
   with procedure Put (Item : Element);
procedure Generic_Print_Twice (Item : Element);

procedure Generic_Print_Twice (Item : Element) is
begin
   Put (Item);
   Put (Item);
end Generic_Print_Twice;

Sous cette forme, on peut substituer la destination de sortie : sortie standard, journal, tampon de test, etc.

Generic_Print_TwiceReçoit Put comme sous-programme formelSortie consoleSortie journalTampon de test

16.4 Ne pas figer le type d’indice du tableau

En Ada, le type des indices d’un tableau est lui aussi une information de type importante. Plutôt que de figer Positive, faire aussi du type d’indice un paramètre formel, selon le besoin, augmente la réutilisabilité.

generic
   type Element is private;
   type Index is (<>);
   type Array_Type is array (Index range <>) of Element;
procedure Generic_Clear (Arr : in out Array_Type; Value : Element);

Cette conception permet de gérer non seulement les tableaux indexés par Positive, mais aussi les tableaux indexés par un type énuméré.

Index is discretPlage PositiveType énuméré DayType énuméré StateType entier personnalisé

17. Liste de vérification pour une API à la manière Ada

Lorsqu’on écrit un générique, revoir les points suivants à la fin en améliore la lisibilité.

Vérification de conception du génériqueLes paramètres formels sont-ils minimaux ?Les opérations nécessaires sont-elles explicitées dans la partie formelle ?Les catégories private / range / digits sont-elles appropriées ?Peut-on instancier de façon lisible avec l'association nommée ?L'état et les exceptions propres à chaque instance sont-ils pris en compte ?L'augmentation de la taille du code est-elle acceptable ?Dispose-t-on d'instances destinées aux tests ?

En résumé :

  • Toute opération utilisée dans le corps doit toujours être visible sous forme de contrat dans les paramètres formels.
  • Si private suffit, utiliser private. Si l’arithmétique est nécessaire, utiliser range <> ou digits <>.
  • Comparaison, hachage, sortie, conversion : tout comportement qui varie selon le type doit devenir un sous-programme formel.
  • Si la taille ou le seuil relève d’une propriété de l’instance, en faire un paramètre de valeur.
  • S’il y a un état, penser d’abord au paquet générique ; s’il n’y en a pas, penser d’abord au sous-programme générique.
  • À l’instanciation, plus les arguments sont nombreux, plus il faut utiliser l’association nommée.
  • Concevoir en présumant que les exceptions et l’état interne sont indépendants pour chaque instance.

18. Exemple d’organisation complète des échantillons

Si l’on répartit les exemples de l’article en plusieurs fichiers, une organisation comme celle-ci facilite la lecture.

ada-generic-programmingsrcgenericsdemosgeneric_swap.adsgeneric_swap.adbgeneric_stack.adsgeneric_stack.adbgeneric_insertion_sort.adsgeneric_insertion_sort.adbgeneric_statistics.adsgeneric_statistics.adbgeneric_count_if.adsgeneric_count_if.adbgeneric_kv_store.adsgeneric_kv_store.adbswap_demo.adbstack_demo.adbsort_demo.adbstatistics_demo.adbcount_if_demo.adbkv_demo.adb

Pour un petit échantillon d’article, il est pratique de tout regrouper dans un seul fichier puis de le découper avec gnatchop. Mais pour un usage professionnel ou une maintenance à long terme, séparer la spécification .ads du corps .adb donne une organisation plus conforme à l’esprit Ada.

19. Conclusion ── fixer les frontières de la réutilisation par les types

La programmation générique en Ada ne se résume pas à une simple « fonctionnalité permettant d’écrire du code indépendant du type ». Son essence réside plutôt dans le fait de rendre explicite, sous forme de contrat entre types, sous-programmes et valeurs, ce qu’exige un composant réutilisable.

Écrire le contratÉcrire le corps génériqueInstancier en passant types, valeurs, fonctionsUtiliser de façon type-sûreRéutiliser sans copier

Comme on l’a vu dans cet article, les génériques Ada peuvent recevoir comme paramètres formels :

  • Un type
  • Une valeur
  • Un sous-programme
  • Un paquet

De plus, pour les types, on peut spécifier des catégories assez fines : private, limited private, range <>, mod <>, digits <>, delta <>, (<>). Grâce à cela, le corps générique n’a pas à dépendre d’« opérations dont on ne sait pas si elles sont disponibles » : il peut être implémenté de façon sûre, en n’utilisant que les opérations écrites dans le contrat.

Dans le patrimoine du C ou du C++ ancien, la réutilisation passe parfois par des macros, void*, des pointeurs de fonction, ou des branchements de type écrits à la main. Les génériques Ada permettent de remplacer une grande partie de ces usages par une forme type-sûre et lisible. C’est en particulier dans les logiciels à maintenance longue durée, embarqués, temps réel ou à haute fiabilité que cette approche consistant à « fixer les frontières à la compilation » apporte une valeur considérable.

Domaines de conseil associés

KomuraSoft LLC (合同会社小村ソフト) intervient sur le développement d’applications Windows, l’investigation et la remise à niveau d’actifs existants, la clarification des frontières COM / ActiveX / 32 bits / 64 bits, ainsi que le conseil technique et la revue de conception. Au-delà des conceptions orientées typage statique et haute fiabilité comme celles d’Ada, la question de savoir comment organiser et faire perdurer ou migrer des actifs existants en C/C++, C#, VB6, MFC ou COM constitue elle aussi, en pratique, une thématique proche de notre activité.

Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Qu'est-ce qu'un générique en Ada ?
C'est un mécanisme de réutilisation qui reçoit des types, des valeurs, des sous-programmes, voire des paquets eux-mêmes comme paramètres formels, et qui est vérifié statiquement au moment de l'instanciation avec new. Ce n'est pas une simple substitution de texte : à la compilation, le compilateur détermine si « ce composant satisfait bien ce contrat ». Un sous-programme générique ne peut pas être appelé tel quel une fois déclaré ; il ne devient une procédure ou une fonction normale qu'après avoir été instancié avec un type concret.
En quoi les génériques Ada diffèrent-ils des templates C++ ?
Ada adopte dès l'origine un contract model où le contrat est explicite : le corps du générique n'utilise que les opérations promises par les paramètres formels, et il est vérifié seul, indépendamment de toute instanciation. Les templates C++ ont historiquement la particularité de ne révéler leurs erreurs qu'au moment de l'instanciation, un point amélioré par les concepts de C++20. De plus, en Ada, outre les paramètres de valeur et de sous-programme, un paquet entier peut lui-même être un paramètre formel, ce qui permet de composer des composants génériques entre eux.
Pourquoi spécifier une catégorie de type pour un paramètre de type formel en Ada ?
Pour rendre explicites, sous forme de contrat, les opérations utilisables dans le corps du générique. Avec type T is private, on ne peut présumer que des opérations de base comme l'affectation ou la comparaison d'égalité — ni la comparaison d'ordre ni les opérations arithmétiques ne sont disponibles. Si l'on a besoin d'arithmétique entière, on spécifie range <> ; pour l'arithmétique flottante, digits <> ; pour les opérations bit à bit, mod <>. La spécification de catégorie fonctionne aussi comme une spécification au niveau du type, indiquant au lecteur « ce que ce composant exige ».
Quels sont les points de vigilance avec les génériques Ada ?
Comme une instance distincte est créée pour chaque type, un grand nombre d'instanciations peut augmenter la taille du code généré. C'est le même compromis que l'on retrouve avec les templates C++ ou la monomorphisation de Rust. De plus, une exception déclarée dans la spécification d'un paquet générique devient une exception distincte pour chaque instance. En pratique, il est recommandé de commencer avec un minimum de paramètres formels, de n'en ajouter que lorsqu'un besoin concret de substitution apparaît, et d'utiliser l'association nommée pour les instanciations comportant de nombreux arguments.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog