La programmation modulaire II : Encapsulation

Avec ce chapitre, nous allons entrer de plein pied dans la Programmation Orientée Objet (souvent appelée par son doux acronyme de POO). L'objectif de ce chapitre est double :

  • expliquer ce que sont les notions de POO, de classes…;
  • fournir une première application de ce concept de POO en abordant l'encapsulation.

Cette notion de Programmation Orientée Objet est une notion compliquée que nous allons prendre le temps d'aborder au travers de ce chapitre et des quatre suivants (Eh oui, cinq chapitres, ce n'est pas de trop). Alors, prenez un grand bol d'air car avec la POO nous allons faire une longue plongée dans une nouvelle façon de programmer de d'utiliser nos packages, en commençant par l'encapsulation.

Qu'est-ce qu'un objet ?

Une première approche

Au cours de la partie III, nous avons manipulé des types plus complexes : tableaux, pointeurs, types structurés… nous nous sommes ainsi approché de cette notion d'objet en manipulant ce qui n'était déjà plus de simples variables. Je parlais ainsi de variables composites, mais il ne s'agissait pas encore de véritables objets.

Mais alors, c'est quoi un objet ? Si c'est encore plus compliqué que les pointeurs et les types structurés j'abandonne ! :(

Vous vous souvenez de l'époque où nous avions réalisé notre TP sur le craps ? À la suite de cela, je vous avais dit qu'il aurait été plus judicieux que nos nombreuses variables soient toutes rassemblées en un seul et même contenant :

Rassemblement de plusieurs variables en une seule

Avec nos connaissances actuelles, nous savons que ces différentes variables pourraient être rassemblées en un seul type structuré de la manière suivante :

1
2
3
4
5
type T_MainJoueur is record
   de1, de2 : natural range 1..6 ; 
   hasard   : generator ; 
   somme    : natural range 2..12 ; 
end record ;

Voire mieux encore, nous pourrions tout rassembler (le type T_MainJoueur, les variables, les fonctions et les procédures associées) dans un seul package appelé P_MainJoueur ! À la manière suivante :

Rassemblement en un seul package

Ainsi, nous aurions au sein d'un même paquetage à la fois les variables, les types et les programmes nécessaires à son utilisation (génération des dés, lecture de leur somme…). Bref, aujourd'hui, vous ne procèderiez plus comme à l'époque où tout était en vrac dans le programme principal.

En effet, j'admets avoir fait d'énormes progrès en très peu de temps, en primaire déjà mes instituteurs… Eh ! Mais tu dérives là ! :colere: Tu ne réponds pas à ma question : C'EST QUOI UN OBJET ?!?

Un objet ? C'est justement cela : un type de donnée muni d'une structure comprenant l'ensemble des outils nécessaires à la représentation d'un objet réel ou d'une notion. Vous ne devez plus voir vos packages simplement comme une bibliothèque permettant de stocker «tout-et-n'importe-quoi-du-moment-que-ça-ne-me-gène-plus-dans-mon-programme-principal» mais plutôt comme une entité à part entière, porteuse d'un sens et d'une logique propre. Ainsi, pour constituer un objet correspondant à la main d'un joueur, notre package P_MainJoueur devrait contenir un type T_Main_Joueur (englobant les dés et tous les paramètres nécessaires) ainsi que les outils pour lire ou modifier cette main… mais rien qui ne concerne son score par exemple !

Ainsi, concevoir un objet c'est non seulement concevoir un type particulier mais également tous les outils nécessaires à sa manipulation par un tiers utilisateur et une structure pour englober tout cela.

J'insiste sur le mot «nécessaire» ! Prenez par exemple un programmeur qui a conçu un type T_Fenetre avec tout plein de procédures et de fonctions pour faire une jolie fenêtre sous Windows, Linux ou Mac. Il souhaite faire partager son travail au plus grand nombre : il crée donc un package P_Fenetre qu'il va diffuser. Ce package P_Fenetre n'a pas besoin de contenir des types T_Image_3D, ce serait hors sujet. Mais l'utilisateur a-t-il besoin d'accéder aux procédures Creer_Barre_De_Titre, Creer_Bouton_Fermer, Creer_Bouton_Agrandir… ? Non, il n'a besoin que d'une procedure Creer_Fenetre ! La plupart des petits programmes créés par le programmeur n'intéresseront pas l'utilisateur final : il faudra donc permettre l'accès pour l'utilisateur à certaines fonctionnalités, mais pas à toutes !

Mathieu Nebra en parle dans son cours sur le C++. L'illustration qu'il donne est très parlante et peut vous fournir une seconde explication si vous avez encore du mal à comprendre de quoi il retourne. Cliquez ici pour suivre son explication (partie 1 seulement).

Posons le vocabulaire

Pour faire de la programmation orientée objet, il faut que nous établissions un vocabulaire précis. Le premier point à éclaircir est la différence entre les termes «classe» et «objet». La classe correspond à notre type (nous nuancerons cette assertion plus tard) : elle doit toutefois être munie d'un package dans lequel sont décrits le type et les fonctions et procédures pour le manipuler. À partir de ce «schéma», il est possible de créer plusieurs « variables » que l'on nommera objets : on dit alors que l'on instancie des objets à partir d'une classe (de la même manière que l'on déclare des variables d'un certain type).

Cette notion de classe est encore incomplète. Elle sera approfondie lors des chapitres suivants.

Autre point de vocabulaire, les procédures et fonctions liées à une classe et s'appliquant à notre objet sont appelées méthodes. Pour mettre les choses au clair, reprenons l'exemple de notre jeu de craps en image :

Classe et objets

Enfin, certaines catégories de méthodes portent un nom spécifique selon le rôle qu'elles jouent : constructeur pour initialiser les objets, destructeur pour les détruire proprement (en libérant la mémoire dynamique par exemple, nous reverrons cela dans le chapitre sur la finalisation), accesseur, modifieur pour lire ou modifier un attribut, itérateur pour appliquer une modification à tous les éléments d'une pile par exemple…

De nouvelles contraintes

On attache à la Programmation Orientée Objet, non seulement la notion de classe englobant à la fois le type et les programmes associés, mais également divers principes comme :

  • L'encapsulation : c'est l'une des propriétés fondamentales de la POO. Les informations contenues dans l'objet ne doivent pas être «bidouillables» par l'utilisateur. Elles doivent être protégées et manipulées uniquement à l'aide des méthodes fournies par le package. Si l'on prend l'exemple d'une voiture : le constructeur de la voiture fait en sorte que le conducteur n'ait pas besoin d'être mécanicien pour l'utiliser. Il crée un volant, des sièges et des pédales pour éviter que le conducteur ne vienne mettre son nez dans le moteur (ce qui serait un risque). Nous avons commencé à voir cette notion avec la séparation des spécifications et du corps des packages et nous allons y ajouter la touche finale au cours de ce chapitre avec la privatisation.
  • La généricité : nous l'avons régulièrement évoquée au cours de ce tutoriel. Elle consiste à réaliser des packages applicables à divers types de données. Il en était ainsi pour les Doubly_Linked_Lists ou les Vectors qui pouvaient contenir des float, des integer ou des types personnalisés. Nous verrons cette notion plus en profondeur lors du prochain chapitre sur la programmation modulaire.
  • L'héritage et la dérivation : deuxième notion fondamentale de la conception orientée objet. Plus complexe, elle sera traitée après la généricité.

Un package… privé

Partie publique / partie privée

" :soleil: Ici c'est un club privé, on n'entre pas ! :soleil: "

Vous avez peut-être déjà entendu cette tirade ? Eh bien nous allons pouvoir nous venger aujourd'hui en faisant la même chose avec nos packages. :pirate: Bon certes, personne ne comptait vraiment venir danser avec nous devant le tuto… Disons qu'il s'agit de rendre une partie de notre code «inaccessible». Petite explication : lorsque nous avons rédigé nos packages P_Pile et P_File, je vous avais bien précisé que nous voulions proposer à l'utilisateur ou au programmeur final toute une gamme d'outils qui lui permettraient de ne pas avoir à se soucier de la structure des types T_File et T_Pile. Ainsi, pour accéder au deuxième élément d'une pile, il lui suffit d'écrire :

1
2
3
4
5
6
7
8
...
   n : integer ; 
   P : T_Pile
BEGIN
         ...
   --création de la pile
   pop(P,n) ; 
   put(first(P)) ;

La logique est simple : il dépile le premier élément puis affiche le premier élément de la sous-pile restante. Simple et efficace ! C'est d'ailleurs pour cela que nous avions créé toutes ces primitives. Mais en réalité, rien n'empêche notre programmeur d'écrire le code suivant.

1
2
3
4
5
6
...
   P : T_Pile
BEGIN
         ...
   --création de la pile
   put(P.suivant.valeur) ;

Et là, c'est plutôt embêtant non ? Car nous avons rédigé des procédures et des fonctions justement dans le but qu'il ne puisse pas utiliser l'écriture avec pointeurs. Cette écriture présente des risques, nous le savons et avons pu le constater en testant nos packages P_Pile et P_File ! Alors il est hors de question que nous ayons fait tous ces efforts pour fournir un code sûr et qu'un olibrius s'amuse finalement à «détourner» notre travail. Si cela n'a pas de grande incidence à notre échelle, cela peut poser des problèmes de stabilité et de sécurité du code dans de plus grands projets nécessitant la coopération de nombreuses personnes.

Bah oui mais on ne peut pas empêcher les gens de faire n'importe quoi ? :-°

Eh bien si, justement ! La solution consiste à rendre la structure de nos types T_Pile, T_File et T_Cellule illisible en dehors de leurs propres packages. Actuellement, n'importe qui peut la manipuler à sa guise simplement en faisant appel au bon package. On dit que nos types sont publics. Par défaut, tout type, variable, sous-programme… déclaré dans un package est public.

Pour restreindre l'accès à un type, une variable…, il faut donc le déclarer comme privé (PRIVATE en Ada). Pour réaliser cela, vous allez devoir changer votre façon de voir vos packages ou tout du moins les spécifications. Vous les voyiez jusqu'alors comme une sorte de grande boîte où l'on stockait tout, n'importe quoi et n'importe comment. Vous allez devoir apprendre que cette grande boîte est en fait compartimentée.

Organisation des packages

Comme l'indique le schéma ci-dessus, le premier compartiment, celui que vous utilisiez, correspond à la partie publique du package. Il est introduit par le mot IS. Le second compartiment correspond à la partie privée et est introduit par le mot PRIVATE, mais comme cette partie était jusqu'alors vide, nous n'avions pas à la renseigner. Reprenons le code du fichier P_Pile.ads pour lui ajouter une partie privée :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PACKAGE P_Pile IS

         --   PARTIE PUBLIQUE : NOS PRIMITIVES

   PROCEDURE Push (P : IN OUT T_Pile; N : IN Integer) ;
   PROCEDURE Pop (P : IN OUT T_Pile ; N : OUT Integer) ;
   FUNCTION Empty (P : IN T_Pile) RETURN Boolean ;
   FUNCTION Length(P : T_Pile) RETURN Integer ;   
   FUNCTION First(P : T_Pile) RETURN Integer ;

PRIVATE

         --   PARTE PRIVÉE : NOS TYPES

   TYPE T_Cellule;
   TYPE T_Pile IS ACCESS ALL T_Cellule;
   TYPE T_Cellule IS
      RECORD
         Valeur  : Integer;
         Index   : Integer;
         Suivant : T_Pile; 
      END RECORD;
END P_Pile;

Les utilisateurs doivent garder l'accès aux primitives, elles ont été faites pour eux tout de même. En revanche, ils n'ont pas à avoir accès à la structure de nos types, donc nous les rendons privés. Et voilà ! Le tour est joué ! Pas besoin de modifier le corps de notre package, tout se fait dans les spécifications.

À noter qu'il est possible de déclarer des fonctions ou procédures dans la partie PRIVATE. Ces programmes ne pourront dès lors être utilisés que par les programmes du corps du package. Plus d'explications seront fournies ci-après.

Visibilité

Mais ça marche pas tes affaires ! o_O À la compilation, GNAT m'indique "T_Pile" is undefined (more references follow).

Ah oui… c'est embêtant. Cela m'amène à vous parler de la visibilité du code. Lorsque vous créez un programme faisant appel à P_Pile (avec l'instruction « WITH P_Pile ; »), ce programme n'a pas accès à l'intégralité du package ! Il aura simplement accès aux spécifications et, plus précisément encore, aux spécifications publiques ! Toute la partie privée et le corps du package demeurent invisibles, comme l'indique le schéma suivant.

Visibilité d'un package

Une flèche indique qu'une partie «a accès à» ce qui est écrit dans une autre; On voit ainsi que seul le corps du package sait comment est constituée la partie privée puisqu'il a pleine visibilité sur l'ensemble du fichier ads. Donc seul le BODY peut manipuler librement ce qui est dans la partie PRIVATE.

Comment peut-on alors créer un type T_Pile si ce type est invisible ?

L'utilisateur doit pouvoir voir ce type sans pour autant pouvoir le lire. «Voir notre type» signifie que l'utilisateur peut déclarer des objets de type T_Pile et utiliser des programmes utilisant eux-même ce type. En revanche, il ne doit pas en connaître la structure, c'est ce que j'appelle ne pas pouvoir «lire notre type». Comment faire cela ? Eh bien, nous devons indiquer dans la partie publique que nous disposons de types privés. Voici donc le code du package P_Pile tel qu'il doit être écrit (concentrez-vous sur la ligne 5) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
PACKAGE P_Pile IS

         --   PARTIE PUBLIQUE

   TYPE T_Pile IS PRIVATE ;       --On indique qu'il y a un type T_Pile 
                                  --mais que celui-ci est privé

   PROCEDURE Push (P : IN OUT T_Pile; N : IN Integer) ;
   PROCEDURE Pop (P : IN OUT T_Pile ; N : OUT Integer) ;
   FUNCTION Empty (P : IN T_Pile) RETURN Boolean ;
   FUNCTION Length(P : T_Pile) RETURN Integer ;   
   FUNCTION First(P : T_Pile) RETURN Integer ;

PRIVATE

         --   PARTE PRIVÉE

   TYPE T_Cellule;
   TYPE T_Pile IS ACCESS ALL T_Cellule;      --on décrit ce qu'est le type T_Pile
   TYPE T_Cellule IS
      RECORD
         Valeur  : Integer;
         Index   : Integer;
         Suivant : T_Pile; 
      END RECORD;
END P_Pile;

Et cette fois, c'est fini : votre type T_Pile est visible mais plus lisible, vous avez créé un véritable type privé en encapsulant le type T_Cellule.

Un package privé et limité

Que faire avec un type PRIVATE ?

Nous avons dit que le type T_Pile était privé et donc qu'il était visible mais non lisible. Par conséquent, il est impossible de connaître la structure d'une pile P et donc d'écrire :

1
P.valeur := 15 ;

CODE FAUX

En revanche, il est possible de déclarer des variables de type T_Pile, d'affecter une pile à une autre ou encore de tester l'égalité ou l'inégalité de deux piles :

1
2
3
4
5
6
7
-- TOUTES LES OPÉRATIONS CI-DESSOUS SONT THÉORIQUEMENT POSSIBLES
   P1, P2, P3 : T_Pile     --déclaration
BEGIN
      ...
   P2 := P1 ;              --affectation
   if P2 = P1 and P3/=P1   --comparaison (égalité et inégalité seulement)
      then ...

Mais à moins que vous ne les surchargiez, les opérateurs +, *, / ou - ne sont pas définis pour nos types T_Pile et ne sont donc pas utilisables, de même pour les test d'infériorité (< et <=) ou de supériorité (> et >=). Il n'est donc pas possible non plus d'écrire :

1
2
3
P3 := P1 + P2 ;
if P3 < P1
   then ...

CODE FAUX

Restreindre encore notre type

Type limité et privé

Eh ! Mais si P1, P2 et P3 sont des piles, ce sont donc des pointeurs ! C'est pas un peu dangereux d'écrire P2 := P1 dans ce cas ?

Si, en effet. Vous avez bien retenu la leçon sur les pointeurs. Il est effectivement risqué de laisser à l'utilisateur la possibilité d'effectuer des comparaisons et surtout des affectations. Heureusement, le langage Ada a tout prévu ! Pour restreindre encore les possibilités, il est possible de créer un type non plus PRIVATE, mais LIMITED PRIVATE (privé et limité) ! Et c'est extrêmement facile à réaliser puisqu'il suffit juste de modifier la ligne n°5 du fichier ads :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
PACKAGE P_Pile IS

         --   PARTIE PUBLIQUE

   TYPE T_Pile IS LIMITED PRIVATE ;       --Le type T_Pile est désormais privé ET limité ! ! !

   PROCEDURE Push (P : IN OUT T_Pile; N : IN Integer) ;
   PROCEDURE Pop (P : IN OUT T_Pile ; N : OUT Integer) ;
   FUNCTION Empty (P : IN T_Pile) RETURN Boolean ;
   FUNCTION Length(P : T_Pile) RETURN Integer ;   
   FUNCTION First(P : T_Pile) RETURN Integer ;

PRIVATE

         --   PARTE PRIVÉE

   TYPE T_Cellule;
   TYPE T_Pile IS ACCESS ALL T_Cellule;      --on décrit ce qu'est le type T_Pile
   TYPE T_Cellule IS
      RECORD
         Valeur  : Integer;
         Index   : Integer;
         Suivant : T_Pile; 
      END RECORD;
END P_Pile;

Désormais, l'utilisateur ne pourra QUE déclarer ses piles ! Plus d'affectation ni de comparaison !

Mais ?!? o_O À quoi ça sert d'avoir un objet si on ne peut pas lui affecter de valeur ?

Pensez aux fichiers et au type File_Type. Aviez-vous besoin d'effectuer des affectations ou des comparaisons ? Non bien sûr. Cela n'aurait eu aucun sens ! Mais cela ne vous a pas empêché d'employer des objets de type File_Type et de leur appliquer diverses méthodes comme Put(), Get(), End_Of_Page() et tant d'autres. Le principe est exactement le même dans le cas présent : est-ce cohérent d'affecter UNE valeur à une pile ? Je ne crois pas. D'ailleurs, nous avons la primitive Push() pour ajouter une valeur à celles déjà existantes. En revanche, il pourrait être utile d'ajouter quelques méthodes à notre package P_Pile pour copier une pile dans une autre ou pour tester si les piles contiennent un ou des éléments identiques. Le but étant d'avoir un contrôle total sur les affectations et les tests : avec un type LIMITED PRIVATE, rien à part la déclaration ne peut se faire sans les outils du package associé.

Type seulement limité

Pour information, il est possible de déclarer un type qui soit seulement LIMITED. Par exemple :

1
2
3
4
TYPE T_Vainqueur IS LIMITED RECORD
   Nom : String(1..3) ; 
   Score : Natural := 0 ; 
END RECORD ;

Ainsi, il sera impossible d'effectuer une affectation directe ou un test d'égalité (ou inégalité) :

1
2
3
4
5
6
7
...
   Moi,Toi : T_Vainqueur ; 
BEGIN
   Toi := ("BOB",5300) ;     -- Erreurs ! ! ! Pas d'affectation possible !
   Moi := Toi ;
   if Moi = Toi              -- Deuxième erreur ! ! ! Pas de comparaison possible !
      then ...

CODE FAUX

En revanche, puisque le type T_Vainqueur n'est pas privé, sa structure est accessible et il demeure possible d'effectuer des tests ou des affectations sur ses attributs. Le code suivant est donc correct.

1
2
3
4
Toi.nom := "JIM" ; 
If Moi.score > Toi.score
   then put("Je t'ai mis la misere ! ! ! " ; 
end if ;

L'intérêt d'un type uniquement LIMITED est plus discutable et vous ne le verrez que très rarement. En revanche, les types LIMITED PRIVATE vont devenir de plus en plus courants, croyez-moi.

Exercices

Exercice 1

Énoncé

Sur le même principe, modifier votre package P_File afin que votre type T_File soit privé et limité.

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
PACKAGE P_Pile IS

   TYPE T_Pile IS LIMITED PRIVATE ;

   PROCEDURE Push (P : IN OUT T_Pile; N : IN Integer) ;
   PROCEDURE Pop (P : IN OUT T_Pile ; N : OUT Integer) ;
   FUNCTION Empty (P : IN T_Pile) RETURN Boolean ;
   FUNCTION Length(P : T_Pile) RETURN Integer ;
   FUNCTION First(P : T_Pile) RETURN Integer ;

PRIVATE

   TYPE T_Cellule; 
   TYPE T_Pile IS ACCESS ALL T_Cellule;
   TYPE T_Cellule IS
      RECORD
         Valeur  : Integer; 
         Index   : Integer;
         Suivant : T_Pile; 
      END RECORD;

END P_Pile;

Exercice 2

Énoncé

Créer une classe T_Perso contenant le nom d'un personnage de RPG (Jeu de rôle), ses points de vie et sa force. La classe sera accompagnée d'une méthode pour saisir un objet de type T_Perso et d'une seconde pour afficher ses caractéristiques.

Solution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package P_Perso is
   type T_Perso is private ;     --éventuellement limited private
   procedure get(P : out T_Perso) ;
   procedure put(P : in T_Perso) ; 
private
   type T_Perso is record
      Nom   : string(1..20) ; 
      PV    : Natural := 0 ;
      Force : Natural := 0 ; 
   end record ;
end P_Perso ;

P_Perso.ads

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
with ada.text_io ;            use ada.text_io ; 
with ada.integer_text_io ;    use ada.integer_text_io ; 

package body P_Perso is

   procedure get(P : out T_Perso) is
   begin
      put("Entrez un nom (moins de 20 lettres) : ") ; get(P.Nom) ; skip_line ;
      put("Entrez ses points de vie            : ") ; get(P.PV) ; skip_line ;
      put("Entrez sa force                     : ") ; get(P.Force) ; skip_line ;
   end get ; 

   procedure put(P : in T_Perso) is
   begin
      put_line(P.nom) ; 
      put_line("       VIE : " & integer'image(P.PV)) ; 
      put_line("     FORCE : " & integer'image(P.Force)) ; 
   end put ; 
end P_Perso ;

P_Perso.adb

Voilà qui est fait pour notre entrée en matière avec la POO. Rassurez-vous, ce chapitre n'était qu'une entrée en matière. Si l'intérêt de la POO vous semble encore obscur ou que certains points mériteraient des éclaircissement, je vous conseille de continuer la lecture des prochains chapitres durant lesquels nous allons préciser la notion d'objet et de classe. La prochaine étape est la généricité, notion déjà présente en Ada83 et dont je vous ai déjà vaguement parlé au cours des précédents chapitres.


En résumé :

  • En Programmation orientée objet, les types d'objets sont appelés classes. Chaque classe dispose de son propre package.
  • Le terme méthode regroupe les fonctions et les procédures associées à une classe. Toutes les méthodes doivent être fournies avec le package.
  • Lorsque l'on programme « orienté objet », il est important d'encapsuler la classe voire certaines de ses méthodes avec PRIVATE ou même LIMITED PRIVATE.
  • Le corps d'un package a visibilité sur l'ensemble de ses spécifications, publiques ou privées.
  • Un programme ou un second package ne peut avoir accès qu'aux spécifications publiques, celle-ci bloquant l'accès à la partie privée et au corps.