Licence CC BY-NC-SA

La programmation modulaire VI : Finalisation et types contrôlés

Il y a un point dont je vous ai parlé très tôt dans cette partie et sur lequel je ne me suis pas beaucoup étendu (pour ne pas dire pas du tout). Je vous avais dit que procédures et fonctions portaient le nom de méthodes en POO. J'avais également ajouté que certaines méthodes possédaient un nom particulier : constructeur, destructeur, modifieur… C'est à ces trois méthodes spécifiques que nous allons nous intéresser désormais. Pour cela, nous prolongerons (encore une fois) l'exemple vu au chapitre précédent du héros pouvant être de type T_Unit, T_Mounted_Unit ou T_Archer_Unit. Ce sera également l'occasion de revenir sur un oubli (volontaire rassurez-vous).

Objectifs et prérequis

De quoi parle-t-on exactement ?

Moi je veux bien que tu me parles de constructeur mais ça sert à quoi ? Qu'est-ce qu'elles ont de particuliers tes méthodes ?

Constructeur

Par constructeur, on entend une méthode qui va initialiser nos objets. Nos types étant généralement PRIVATE, nous avons été obligés de fournir une (ou plusieurs) méthode(s) Init() avec nos classes. C'est ce que l'on appelle un constructeur. Mais que se passe-t-il si l'on n'initialise pas un objet de type T_Unit par exemple ? Eh bien, le programme va réserver un espace en mémoire pour enregistrer notre objet, mais cet espace ne contiendra aucune information valide. Et si nous sommes amenés à lire cet objet, nous ferons alors lamentablement planté notre ordinateur. Il est donc important d'initialiser nos objets. Une solution consiste à prendre l'habitude de le faire systématiquement et de faire en sorte que les personnes qui travaillent avec vous le fasse elles-aussi systématiquement. Mais ce qui serait préférable, ce serait que nos objets s'initialisent d'eux-mêmes (cela éviterait les oublis ^^ ). Nous allons justement rédiger des constructeurs par défaut "automatiques".

Destructeur

Je vous avais expliqué lors du chapitre sur les pointeurs qu'il était important de désallouer la mémoire lorsqu'un pointeur ne servait plus, tout en spécifiant que cette opération était risquée (problème de pointages multiples). Ainsi au chapitre précédent, je vous avais fourni un code similaire à ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
...
type T_Heros is access all T_Unit'class ;
Massacror, Vengeator : T_Heros ; 
begin
   loop
      Massacror := Choix_Unite ; 
      Vengeator := Choix_Unite ; 
      Tour_Joueur(Massacror.all,Vengeator.all) ; 
      Tour_Joueur(Vengeator.all,Massacror.all) ; 
   end loop ;
end ;

Supposons qu'il s'agisse là non pas du programme principal mais d'un sous-programme. Que se passe-t-il en mémoire après la ligne 11 ? Les pointeurs Massacror et Vengeator terminent leur portée : ils arrivent au bout du programme dans lequel ils avaient été définis. Les emplacements mémoires liés à ces deux pointeurs sont donc libérés. Oui mais un pointeur n'est jamais qu'une adresse ! C'est bien beau de se débarrasser des adresses mais qu'advient-il des données situées à ces fameuses adresses pointées ? Mystère. Certains compilateurs Ada disposent de ce que l'on appelle un ramasse-miette (ou garbage collector) qui se charge de libérer les emplacements mémoires dont on a perdu l'adresse, mais ce n'est nullement une obligation imposée par la norme ! Donc mieux vaut ne pas compter dessus. Ce qu'il faut faire, comme vous le savez désormais, c'est faire appel au package Ada.unchecked_Deallocation :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
With Ada.Unchecked_Deallocation ; 
...
   type T_Heros is access all T_Unit'class ;
   procedure free is new Ada.Unchecked_Deallocation(T_Unit'class,T_Heros) ; 

   Massacror, Vengeator : T_Heros ; 
begin
   loop
      Massacror := Choix_Unite ; 
      Vengeator := Choix_Unite ; 
      Tour_Joueur(Massacror.all,Vengeator.all) ; 
      Tour_Joueur(Vengeator.all,Massacror.all) ; 
   end loop ;
   free(Massacror) ; 
   free(Vengeator) ;
end ;

Cette procédure est lourde, et il serait plus pratique que cette désallocation se fasse automatiquement. Ce sera donc le rôle des destructeurs de mettre fin proprement (et automatiquement) à la vie d'un objet dès lors qu'il aura atteint la fin de sa portée.

Modifieur

Les modifieurs, quant à eux, ont pour but de gérer les affectations. Quel intérêt ? Eh bien cela vous permettra de mieux contrôler les opérations d'affectations. Que se passerait-t-il si un utilisateur entrait 0H 75Min 200s pour un temps, 35/13/2001 pour une date ? Rien de bon, c'est sûr. Que se passerait-t-il si un programmeur, utilisant vos packages sans en connaître le détail, écrivait « Massacror := Vengeator » ou « pile1 := pile2 » (voire chapitre sur les TAD) ? Inconscient qu'il manipule en réalité des pointeurs, il affecterait une adresse pensant affecter des données, prenant ainsi des risques quant à la sécurité de son programme. Bref, il est bon dans certains cas de maîtriser les opérations d'affectation sans pour autant les interdire (contrairement aux types LIMITED PRIVATE par exemple). Les modifieurs seront donc des procédures qui seront appelées (automatiquement là encore) dès lors qu'une affectation aura lieu.

Comment s'y prendre ?

Bon, je pense pouvoir faire cela tout seul, sauf une chose : comment faire pour que ces méthodes soient appelées automatiquement ? :euh:

Il n'y a pas de technique particulière à apprendre, seulement un type particulier (ou deux) à employer. Les seuls types Ada permettant d'avoir un modifieur, un constructeur et un destructeur par défaut appelés automatiquement, sont les types dits contrôlés (appelés controlled). Un second type permet de disposer d'un constructeur et d'un destructeur automatiques (mais pas d'un modifieur), il s'agit du type limited_controlled.

Ces deux types ne sont pas des types standards, vous devrez donc faire appel au package Ada.Finalization pour les utiliser. Si vous prenez la peine d'ouvrir le fichier a-finali.ads vous découvrirez la déclaration de ces deux types :

1
2
type Controlled is abstract tagged private;
type Limited_Controlled is abstract tagged limited private;

Eh oui ! Ces deux types sont des types privés, abstraits et donc étiquetés ; le type Limited_Controlled est même privé et limité. Mais vous savez ce que cela signifie désormais : ces deux types sont inutilisables en l'état, il va falloir les dériver. :p Et si vous continuez à regarder ce package, vous trouverez les méthodes suivantes :

1
2
3
4
5
6
procedure Initialize (Object : in out Controlled);
procedure Adjust     (Object : in out Controlled);
procedure Finalize   (Object : in out Controlled);
            ...
procedure Initialize (Object : in out Limited_Controlled);
procedure Finalize   (Object : in out Limited_Controlled);

Ce sont les fameuses méthodes dont je vous parle depuis le début de ce chapitre, méthodes qu'il nous faudra réécrire :

  • Initialize() est notre constructeur,
  • Finalize() est notre destructeur,
  • Adjust() est notre modifieur.

Mise en œuvre

Mise en place de nos types

Bon c'est bien beau tout cela, mais j'en fais quoi, moi, de ces types contrôlés ?

Eh bien dérivons-les ! Ou plutôt implémentons-les puisque ce sont des types abstraits. Reprenons l'exemple du type T_Heros et étoffons-le : un héros disposera d'un niveau d'expérience. Ce qui nous donne la déclaration suivante :

1
2
3
4
5
6
7
8
--On définit notre type "pointeur sur la classe T_Unit"
Type T_Unit_Access is access all T_Unit'class ; 

--On définit ensuite notre type T_Heros !
Type T_Heros is new Controlled with record
   exp : integer ;
   unit      : T_Unit_Access ; 
end record ;

Nous obtenons ainsi un type contrôlé, c'est à dire un type étiqueté dérivant du type controlled. Et puisque ce type est étiqueté, nous pouvons également dériver le type T_Heros à volonté, par exemple en créant un nouveau type fils appelé T_Super_Heros. Les super-héros auront eux un niveau de mana pour utiliser de la magie ou des super-pouvoirs :

1
2
3
type T_Super_Heros is new T_Heros with record
   mana : integer ; 
end record ;

Ce nouveau type sera lui aussi contrôlé : comme vous devez maintenant le savoir, tout type dérivant de T_Heros sera lui aussi un type contrôlé.

Les types limited_controlled, fonctionnent de la même manière. Les codes ci-dessus sont donc directement transposables aux types contrôlés et limités. Vous comprendrez donc que je ne détaille pas plus avant leur implémentation.

Mise en place de nos méthodes

Venons-en maintenant, à nos constructeurs, modifieurs et destructeurs. Nous ne traiterons que le cas des types controlled.

Constructeur

Commençons par réécrire notre constructeur Initialize() :

1
2
3
4
5
procedure Initialize (Object : in out T_Heros) is
begin
   Object.exp := 0 ; 
   Object.unit := null ; 
end Initialize ;

Le code ci-dessus initialise notre Héros avec des valeurs nulles par défaut. Mais il est également possible de laisser la main à l'utilisateur :

1
2
3
4
5
6
7
procedure Initialize (Object : in out T_Heros) is
begin
   Put_line("Quelle est le niveau d'experience de votre heros ?") ; 
   get(Object.exp) ; skip_line ; 
   Object.unit := choix_unit ;          --on appelle ici la méthode d'initialisation qui demande 
                                        --à l'utilisateur de choisir entre plusieurs types d'unités
end Initialize ;

Dès lors, il suffira que vous déclariez un objet de type T_Heros pour que cette procédure soit automatiquement appelée. La ligne ci-dessous :

1
Massacror : T_Heros ;

équivaudra à elle seule à celle-ci :

1
Massacror : T_Heros := Massacror.Initialize ;    --c'est plus long et surtout complètement inutile

Destructeur

Le destructeur sera généralement inutile. Mais dans le cas d'un type nécessitant des pointeurs, je vous recommande grandement son emploi afin de vous épargner les nombreuses désallocation de mémoire. Commençons par établir notre procédure de libération de mémoire :

1
2
Type T_Unit_Access is access all T_Unit'class ; 
procedure free is new Ada.Unchecked_Deallocation(T_Unit'class, T_Unit_Access) ;

Pour notre méthode Finalize(), la seule chose que nous avons à faire, c'est de libérer la mémoire pointée par l'attribut Unit. Cela nous donnerait ceci :

1
2
3
4
procedure Finalize (Object : in out T_Heros) is
begin
   free(Object.unit) ; 
end Finalize ;

Prenons un exemple d'utilisation désormais. Déclarons un objet de type T_Heros soit dans une procédure-fonction soit dans un bloc de déclaration :

1
2
3
4
5
6
7
8
...
   Massacror : T_Heros ;      --appel automatique de Initialize
begin
   ...
   --on effectue ce que l'on veut avec notre objet Massacror
end ; 
   --la portée de l'objet Massacror s'arrête ici
   --la méthode Finalize est appelée automatiquement

Modifieur

Finissons en beauté avec le modifieur. Pour bien comprendre, nous allons commencer par un exemple d'application de la méthode Adjust() :

1
2
3
4
5
...
   Massacror, Vengeator : T_Heros ; 
begin
   Massacror := Vengeator ;            --affectation d'un objet vers un autre
...

Mais ce petit bout de code anodin présente deux soucis :

  • L'attribut-pointeur Massacror.Unit est modifié, certes, mais qu'advient-il de l'emplacement mémoire sur lequel il pointait ? Eh bien cet emplacement n'est pas libéré et de plus, vous perdez son adresse !
  • L'attribut Massacror.unit est un pointeur : il va donc pointé sur le même emplacement mémoire que Vengeator.Unit alors qu'il serait préférable qu'il pointe sur un nouvel emplacement mémoire dont le contenu serait identique à celui de Vengeator.Unit.

Voilà pourquoi il est important de définir des types contrôlés. Ces deux problèmes peuvent être réglés : le premier sera réglé grâce à la méthode Finalize(), le second va constituer le cahier des charges de notre procédure adjust().

Mais comment faire une procédure d'affectation avec un seul paramètre ?

Eh bien, vous devez comprendre que la procédure Adjust() ne se charge pas de l'affectation en elle-même mais de l'ajustement de notre objet. Lors d'une affectation, la procédure Finalize() est appelée en tout premier (détruisant ainsi l'objet Massacror et résolvant le premier problème), puis l'affectation est opérée (réalisant le second problème). La méthode Adjust() est enfin appelée en tout dernier lieu pour rectifier les erreurs commises à l'affectation. Il nous reste donc seulement à créer un nouvel emplacement mémoire sur lequel pointera Massacror.Unit :

1
2
3
4
5
6
procedure Adjust (Object : in out T_Heros)is
   Temp : T_Unit'class := Object.Unit.all ;
begin
   Object.Unit := new T_Unit'class ; 
   Object.Unit.all := Temp ; 
end ;

Voila notre problème réglé : l'attribut-pointeur va désormais pointé sur un nouvel emplacement mémoire au contenu identique. Maintenant que nos trois méthodes sont écrites, revenons à notre exemple :

1
2
3
4
5
6
7
8
9
...
   Massacror, Vengeator : T_Heros ;      -- appel automatique de Initialize
begin
   ...
   Massacror := Vengeator ;              -- 1. appel automatique de Finalize
                                         -- 2. affectation
                                         -- 3. appel automatique de Adjust
   ...
end ;                                    -- appel automatique de Finalize

Avant d'en venir aux types limited_Controlled, un dernier exemple d'application de la méthode Adjust avec un type T_Horaire. Comme dit au début de ce chapitre, un horaire ne peut valoir 0H 75Min 200s ! Définissons donc un type contrôlé T_Horaire et sa méthode Adjust().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type T_Horaire is new Controlled with record 
   H, Min, Sec : Natural ; 
end record ; 

procedure Adjust (Object : in out T_Heros)is
begin
   if Object.sec > 60
      then Object.H   := Object.H + Object.sec / 3600 ; 
           Object.sec := Object.sec mod 3600 ; 
           Object.Min := Object.Min + Object.sec / 60 ; 
           Object.sec := Object.sec mod 60 ; 
   end if ; 
   if Object.Min > 60
      then Object.H   := Object.H + Object.min / 60 ; 
           Object.min := Object.min mod 60 ; 
   end if ; 
end Adjust ;

Types contrôlés et limités

Les types Limited_Controlled fonctionnent exactement comme les types Controlled, à la différence qu'ils sont limités. Aucune affectation n'est donc possible avec ces types et la méthode Adjust() est donc inutile.

C'en est cette fois terminé des longs chapitres de théorie sur la Programmation Orientée Objet. Ce dernier chapitre sur la Programmation Orientée Objet aura été le plus court de tous, mais comme il nécessitait des notions sur les types limités, la dérivation des types étiquetés, la programmation par classe de type… nous ne pouvions commencer par lui. Vous aurez ainsi vu les notions essentielles de la POO : encapsulation, généricité, héritage, dérivation, abstraction, polymorphisme et pour finir, finalisation. Vous allez enfin pouvoir passer à un bon vieux TP pour mettre tout cela en pratique. Après quoi, nous aborderons des thèmes aussi différents et exigeants que les exceptions, l'interfaçage ou le multitasking.


En résumé :

  • La finalisation vous permet d'automatiser l'emploi de certaines méthodes.
  • Le constructeur Initialize() est appelé automatiquement lors de la déclaration de vos objets.
  • Le destructeur Finalize() est appelé automatiquement lorsqu'un objet arrive en fin de vie (à la fin d'une sous-programme ou d'un bloc de déclaration).
  • Le modifieur Adjust() va normaliser vos objets après chaque affectation
  • Lors d'une affectation d'un objet à un autre, Ada utilisera tout d'abord son destructeur avant d'affecter, puis il exécutera automatiquement le modifieur.
  • Le package Ada.Finalization propose deux types contrôlés (Controlled et Limited_Controlled) qui, étant abstraits, doivent être dérivés.