POO à gogo - Notions fondamentales de Programmation Orientée Objet avec Free Pascal/Lazarus

Gilles Vasseur

Faites-vous partie de ceux qui ne s'aventurent guère au-delà du noyau historique de Pascal ? Pourtant toutes les applications graphiques et tous les composants produits avec Lazarus sont tributaires de la Programmation Orientée Objet. Plus encore : bien qu'il ne se veuille pas contraignant, le compilateur Free Pascal qui sous-tend cet environnement a été entièrement pensé pour manipuler des objets à travers le concept de classe. Alors, lancez-vous ! Avec ce tutoriel, vous oserez enfin aborder les notions fondamentales relatives à la Programmation Orientée Objet avec Free Pascal.

Commentez

- Titre : POO à gogo - Notions fondamentales de Programmation Orientée Objet avec Free Pascal/Lazarus

- Auteur : Gilles Vasseur

- Parution : 20 juin 2016

- Mise à jour : 5 juin 2018

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2016 Gilles Vasseur. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Classes et objets

- I-A. La programmation orientée objet

- I-B. Les classes

- I-C. Champs, méthodes et propriétés

- I-D. Les instances de classes

- I-E. Notion de constructeur

- I-F. Le paramètre implicite Self

- I-G. Notion de destructeur

- I-H. Premiers gains de la POO

- II. Principes et techniques de la POO

- II-A. L'encapsulation

- II-B. Notion de portée

- II-C. L'héritage

- II-D. Le polymorphisme

- II-E. Les opérateurs Is et As

- III. Bilan

I - Classes et objets

Les programmes de test sont présents dans le répertoire exemplesxemples accompagnant le présent document.

I-A - La programmation orientée objet

Par le regroupement des instructions au sein de modules appelés fonctions et procédures, la programmation structurée a permis une meilleure lisibilité et une maintenance améliorée des programmes. En créant ces éléments plus faciles à comprendre qu’une longue suite d’instructions et de sauts, les projets complexes devenaient maîtrisables.

La Programmation Orientée Objet (POO) se propose de représenter de manière encore plus rigoureuse et plus efficace les entités et leurs relations en les encapsulant au sein d’objets. Elle renverse d’une certaine façon la perspective en accordant toute leur place aux données alors que la programmation structurée privilégiait les actions.

Le constat initial est qu'en matière informatique décrire le monde qui nous entoure consiste essentiellement à utiliser des trios de données : entité, attribut, valeur. Par exemple : (ordinateur, système d’exploitation, Windows 10), (ordinateur, système d’exploitation, Linux Mint 17), (chien, race, caniche), (chien, âge, 5), (chien, taille, petite), (cheveux, densité, rare). L’entité décrite est à l’intersection d’attributs variés qui servent à caractériser les différentes entités auxquelles ils se rapportent.

Ces trios prennent tout leur sens avec des méthodes pour les manipuler : création, insertion, suppression, modification, etc. Plus encore, les structures qui allieront les attributs et les méthodes pourront interagir afin d’échanger les informations nécessaires à un processus. Il s'agit donc de manipuler des entités en mémoire, chacune d’entre elles se décrivant par un ensemble d’attributs et un ensemble de méthodes portant sur ces attributs (cf. L'orienté objet – Bersini Hugues – Eyrolles 2007).

I-B - Les classes

La réunion des attributs et des méthodes pour leur manipulation dans une même structure est le fondement de la POO : cette structure particulière prend le nom de classe. Par une première approximation, vous considérerez une classe comme un enregistrement qui posséderait les procédures et les fonctions pour manipuler ses propres données. Vous pouvez aussi voir une classe comme une boîte noire fournissant un certain nombre de fonctionnalités à propos d’une entité aux attributs bien définis. Peu importe ce qu’il se passe dans cette boîte dans la mesure où elle remplit pleinement les tâches pour lesquelles elle a été conçue.

Imaginez un programme qui créerait des animaux virtuels et qui les animerait. En programmation procédurale classique, vous auriez à coder un certain nombre de fonctions, de procédures et de variables. Ce travail pourrait donner lieu à des déclarations comme celles-ci :

var
V_Nom: string;
V_AFaim: Boolean;
V_NombreAnimaux: Integer;
// […]
procedure Avancer;
procedure Manger;
procedure Boire;
procedure Dormir;
function ASoif: Boolean;
function AFaim: Boolean;
function ANom: string;
procedure SetSoif(Valeur: Boolean);
procedure SetFaim(Valeur: Boolean);
procedure SetNom(const Valeur: string);

Les difficultés commenceraient avec l’association entre les routines définies et un animal particulier. Vous pourriez, par exemple, créer un enregistrement représentant l’état d’un animal :

TEtatAnimal = record
fNom: string;
fAFaim: Boolean;
fASoif: Boolean;
end;

Il vous faudrait ensuite regrouper les enregistrements dans un tableau et mettre en œuvre des techniques pour reconnaître les animaux, fournir leur état et en décrire le comportement. Sans doute que certaines de vos routines auraient besoin d’un nouveau paramètre en entrée capable de distinguer l’animal qui fait appel à elles. Avec des variables globales, des tableaux, des boucles et beaucoup de patience, vous devriez vous en tirer. Cependant, si le projet prend de l’ampleur, les variables globales vont s’accumuler tandis que les interactions entre les procédures et les fonctions vont se complexifier : une erreur se glissera probablement dans leur intrication et il sera difficile de l’y déceler.

Dans un tel cas de figure, la POO montre d’emblée son efficacité. Pour résoudre le même problème, il vous faudra déclarer une classe :

TAnimal = class
strict private
fNom: string;
fASoif: Boolean;
fAFaim: Boolean;
procedure SetNom(const AValue: string);
public
procedure Avancer;
procedure Manger;
procedure Boire;
procedure Dormir;
published
property ASoif: Boolean read fASoif write fASoif;
property AFaim: Boolean read fAFaim write fAFaim;
property Nom: string read fNom write SetNom;
end;

Ne vous inquiétez pas si vous ne comprenez pas en détail (voire pas du tout) le contenu de cette structure : il sera étudié plus tard.

À chaque fois qu’une variable sera du type de la classe définie, elle disposera à titre privé des champs, des propriétés et des méthodes proposées par cette classe. Vous n’aurez besoin de rien de plus : chaque animal particulier sera reconnu grâce à la variable dédiée et seules ses caractéristiques seront prises en compte, sans intervention de votre part.

Par la suite, il s’agira bien sûr de donner chair à cette classe en renseignant chacun de ses éléments.

I-C - Champs, méthodes et propriétés

Les champs (ou attributs) décrivent la structure de la classe. Par exemple, fASoif est un champ de type booléen de TAnimal.

Les méthodes (procédures et fonctions) décrivent les opérations qui sont applicables grâce à la classe. Par exemple, Avancer est une méthode de TAnimal.

Une propriété est avant tout un moyen d’accéder à un champ : ainsi fNom est accessible grâce à la propriété Nom. Les propriétés se servent des mots réservés read et write pour cet accès.

Mais trêve de théorie : place à votre première expérimentation des classes !

[Exemple PO_01]

Pour avoir accès à la POO avec Free Pascal, vous devez activer l’une des trois options suivantes :

- {$mode objfpc} ;

- {$mode delphi} ;

- {$mode MacPas}.

La première ligne est incluse automatiquement dans le squelette de l’application lorsque vous la créez via Lazarus.

Afin de préparer votre premier travail en relation avec les classes, procédez comme suit :

- créez une nouvelle application ;

- avec « Fichier » -> « Nouvelle unité », ajoutez une unité à votre projet ;

- enregistrez les squelettes créés automatiquement par Lazarus sous les noms suivants : project1.lpi sous TestPOO01.lpi – unit1.pas sous main.pas – unit2.pas sous animal.pas ;

- rebaptisez la fiche principale en modifiant sa propriété Name  : appelez-la MainForm, ce qui aura pour conséquence immédiate de transformer aussi le type de la classe de la fiche de TForm1 à TMainForm ;

- dans la partie interface de l’unité animal, créez une section type et entrez le code de définition de la classe TAnimal ;

- placez le curseur n’importe où dans la définition de la classe puis pressez simultanément sur Ctrl-Maj-C : Lazarus va créer instantanément le squelette de toutes les méthodes à définir.

À ce stade, votre unité devrait ressembler à ceci :

unit animal;

{$mode objfpc}{$H+}

interface

uses
Classes, SysUtils;

type
{ TAnimal }

TAnimal = class
strict private
fNom: string;
fASoif: Boolean;
fAFaim: Boolean;
procedure SetNom(const AValue: string);
public
procedure Avancer;
procedure Manger;
procedure Boire;
procedure Dormir;
published
property ASoif: Boolean read fASoif write fASoif;
property AFaim: Boolean read fAFaim write fAFaim;
property Nom: string read fNom write SetNom;
end;

implementation

{ TAnimal }

procedure TAnimal.SetNom(const AValue: string);
begin
if fNom = AValue then Exit;
fNom := AValue;
end;

procedure TAnimal.Avancer;
begin
end;

procedure TAnimal.Manger;
begin
end;

procedure TAnimal.Boire;
begin
end;

procedure TAnimal.Dormir;
begin
end;

end.

Une des méthodes est pré-remplie : il s’agit de SetNom qui détermine la nouvelle valeur de la propriété nom. Ne vous inquiétez pas de son contenu qui, à ce stade, n’est pas utile à votre compréhension.

La déclaration d’une classe se fait donc dans une section type de la partie interface de l’unité. On parle aussi d’interface à son propos, c’est-à-dire, dans ce contexte, à la partie visible de la classe. Il faudra bien sûr définir les comportements (que se passe-t-il dans le programme lorsqu’un animal mange ?) dans la partie implementation de la même unité.

Pour être plus précis, la déclaration d'une classe doit se faire dans la partie à la portée la plus large d'un programme ou d'une unité, ou dans une autre classe (type imbriqué). Il est interdit de déclarer une classe dans une procédure ou une fonction.

La seule différence entre la définition d’une méthode et celle d’une routine traditionnelle est que son identificateur porte le nom de la classe comme préfixe, suivi d’un point :

implementation
// […]
procedure TAnimal.Avancer;
begin
end;

Complétez à présent votre programme :

- ajoutez une clause uses à la partie implementation de l’unité animal ;

- complétez cette clause par Dialogs afin de permettre l’accès aux boîtes de dialogue ;

- insérez dans chaque squelette de méthode (sauf SetNom) une ligne du genre : MessageDlg(Nom + ' mange...', mtInformation, [mbOK], 0); en adaptant bien entendu le verbe à l’intitulé de la méthode.

Vous aurez compris que les méthodes complétées afficheront chacune un message comprenant le nom de l’animal tel que défini par sa propriété Nom, suivi d’un verbe indiquant l’action en cours.

Reste à apprendre à utiliser cette classe qui n’est jusqu’à présent qu’une boîte sans vie.

I-D - Les instances de classes

Contrairement à la classe qui est une structure abstraite, l’objet est la concrétisation de cette classe : on parlera d’instanciation pour l’action qui consiste essentiellement à allouer de la mémoire pour l’objet et à renvoyer un pointeur vers l’adresse de son implémentation. L’objet lui-même est une instance d’une classe. Ainsi, dans l’exemple en cours de rédaction, Nemo, Rantanplan et Minette pourront être trois variables, donc trois instances pointant vers trois objets de type TAnimal (la classe). Autrement dit, une classe est un moule et les objets sont les entités réelles obtenues à partir de ce moule.

Vous verrez souvent le terme objet employé dans le sens de classe, voire le contraire. Ces différences de vocabulaire ne doivent pas vous brouiller l'esprit…

Pour avancer dans la réalisation de votre programme d’exemple, procédez comme suit :

- ajoutez cinq composants à votre fiche principale (TListBox, TradioGroup comprenant trois TRadioButton), en les plaçant et les renommant selon le modèle suivant :

- cliquez sur la propriété Items du composant TListBox et complétez la liste qui apparaît, toujours selon le modèle précédent : « Avancer », « Manger », « Boire » et « Dormir » ;

- dans la clause uses de la partie interface de l'unité main, ajoutez animal afin que cette unité soit connue à l’intérieur de la fiche principale :

uses
Classes, SysUtils, FileUtil, Forms, Controls, Graphics, Dialogs, StdCtrls,
ExtCtrls,
animal; // unité de la nouvelle classe

Pour basculer facilement de l'unité en cours à la fiche qui lui correspond et vice versa, il suffit de presser la touche F12.

- dans la partie private de l’interface de l’unité main, définissez quatre variables de type TAnimal : Nemo, Rantanplan, Minette et UnAnimal :

private
{ private declarations }
Nemo, Rantanplan, Minette, UnAnimal: TAnimal;
public
{ public declarations }
end;

Vous avez ainsi déclaré trois animaux grâce à trois variables de type TAnimal. Il suffira que vous affectiez une de ces variables à la quatrième (UnAnimal) pour que l’animal concerné par vos instructions soit celui choisi :

UnAnimal := Rantanplan;

Pour les (très) curieux, cette affectation est possible parce que ces variables sont des pointeurs vers les objets définis.

La façon d’appeler une méthode diffère de celle d’une routine traditionnelle seulement dans la mesure où elle doit à la moindre ambiguïté être préfixée du nom de l’objet qui l'invoque. Les deux éléments sont alors séparés par un point :

UnAnimal := Nemo;
UnAnimal.Avancer; // Nemo sera concerné
UnAnimal.Dormir;
UnAnimal.ASoif := False;

C’est ce que vous allez implémenter en créant les gestionnaires OnClick des composants lbAction, rbMinette, rbNemo et rbRantanplan.

Pour ce faire :

- cliquez tour à tour sur chacun des composants pour le sélectionner puis double-cliquez sur OnClick de son volet « Événements » ;

- complétez le corps des méthodes créées automatiquement par Lazarus comme suit :

procedure TMainForm.lbActionClick(Sender: TObject);
// *** choix d'une action ***
begin
case lbAction.ItemIndex of // élément choisi dans TListBox
0: UnAnimal.Avancer;
1: UnAnimal.Manger;
2: UnAnimal.Boire;
3: UnAnimal.Dormir;
end;
end;

procedure TMainForm.rbMinetteClick(Sender: TObject);
// *** l'animal est Minette ***
begin
UnAnimal := Minette;
end;

procedure TMainForm.rbNemoClick(Sender: TObject);
// *** l'animal est Némo ***
begin
UnAnimal := Nemo;
end;

procedure TMainForm.rbRantanplanClick(Sender: TObject);
// *** l'animal est Rantanplan ***
begin
UnAnimal := Rantanplan;
end;

I-E - Notion de constructeur

Si vous lancez l’exécution de votre programme à ce stade, la compilation se déroulera normalement et vous pourrez agir sur les boutons radio sans problème. Cependant, tout clic sur une action à réaliser par l’animal sélectionné provoquera une erreur fatale :

Dans son jargon, Lazarus vous prévient que le programme a rencontré une erreur de type « External: SIGSEGV ». Cette erreur survient quand vous tentez d’accéder à des emplacements de mémoire qui ne vous sont pas réservés.

L’explication de l’erreur est simple : l’objet en tant qu’instance d’une classe occupe de la mémoire sur le tas (heap), aussi est-il nécessaire de l’allouer et de la libérer. Vous utiliserez à cette fin un constructeur (constructor) dont celui par défaut est Create, et un destructeur (destructor) qui répond toujours au doux nom de Destroy.

Lorsque vous déclarez une variable de type classe, vous n'allouez qu'un emplacement sur la pile (stack) qui correspond à la taille d'un pointeur sur un objet. Ce dernier est créé sur le tas (heap) aux dimensions beaucoup plus généreuses.

Comme le monde virtuel est parfois aussi impitoyable que le monde réel, vous donnerez naissance aux animaux et libérerez la place en mémoire qu’ils occupaient quand vous aurez décidé de leur disparition. Autrement dit, il est de votre responsabilité de tout gérer, y compris la propreté de votre arche de Noé !

L’instanciation de TAnimal prendra cette forme :

Nemo := TAnimal.Create; // création de l’objet
// Ici, le travail avec l’animal créé…

La ligne qui crée l’objet est à examiner avec soin. L’objet n’existant pas avant sa création (un monde impitoyable peut être malgré tout rationnel), vous ne pourriez pas écrire directement une ligne comme :

MonAnimal.Create; // je crois créer, mais je ne crée rien !

Le compilateur ne vous alerterait pas parce qu’il penserait que vous voulez faire appel à la méthode Create de l’objet MonAnimal, ce qui est tout à fait légitime à la conception (et à l’exécution si l’objet est déjà créé) : en effet, même s’il est rare d’avoir à le faire, il est possible d’appeler autant de fois que vous le désirez la méthode Create. Dans le cas présent, le problème est que vous essayeriez ainsi d’exécuter une méthode à partir d’un objet MonAnimal qui n’existe pas encore puisque non créé… Une erreur de violation d’accès serait immédiatement déclenchée à l’exécution, car la mémoire nécessaire à l’objet n’aurait pas été allouée !

C’est toujours en mentionnant le nom de la classe (ici, TAnimal) que l’on crée un objet.

Que vous utilisiez Create ou un constructeur spécifique, le fonctionnement de l’instanciation est le suivant :

- le compilateur réserve de la place sur la pile (stack) pour la variable du type de la classe à instancier : c’est un pointeur ;

- le constructeur de la classe appelle getmem pour réserver la place sur le tas (heap) pour tout l’objet, initialise cette zone de mémoire et renvoie un pointeur vers elle.

La zone mémoire occupée par l’objet étant mise à zéro dès sa création, il n’est pas nécessaire d’initialiser les champs et les propriétés si vous souhaitez qu’ils prennent leur valeur nulle par défaut : chaîne vide, nombre à zéro, pointeur à nil… Cependant, initialiser les champs malgré tout est une bonne habitude qui vous évitera bien des déboires lors de la recherche d'erreurs : le code produit n'en sera pas alourdi et vous aurez une vision directe de la valeur par défaut de vos champs.

I-F - Le paramètre implicite Self

À partir du moment où un objet a été créé, une variable appelée Self est définie implicitement pour chaque méthode de cet objet. Cette variable renvoie une référence aux données de l’objet. Son utilisation la plus fréquente est de servir de paramètre à une méthode ou à une routine qui a besoin de cette référence.

Par exemple, vous pouvez créer un bouton par votre code en lui désignant un parent et un propriétaire ainsi :

MonButton := TButton.Create(Self); // création avec un propriétaire
with MonButton do
begin
Height := 10;
Left := 10;
Top := 10;
Width := 100;
Caption := 'Ouvrir...';
OnClick := @ButtonClick;
Parent := Self; // le parent permet l'affichage
end;

Dans cet exemple, le bouton est créé en utilisant l'instance en cours (une fiche TForm dans la plupart des cas) représentée par le paramètre implicite Self, ce qui permettra la libération automatique du nouvel objet dès que son propriétaire sera lui-même libéré.

La LCL de Lazarus définit la classe ancêtre de tous les composants : TComponent. Cette dernière déclare la propriété Owner qui autorise la libération des objets détenus par leur propriétaire de manière automatique dès que ce propriétaire est libéré. Voilà pourquoi, tout à fait exceptionnellement, vous n'avez pas à libérer les objets que sont les composants déposés sur une fiche : ils le sont quand nécessaire par leur propriétaire !

La dernière ligne concernant ce bouton permet de renseigner la propriété Parent indispensable à l'affichage du contrôle : par exemple, la valeur attribuée à la propriété Left n'a de sens que par rapport à un repère qui est justement son parent. Encore une fois, c'est le paramètre implicite Self qui fournit l'information nécessaire.

Avec la LCL de Lazarus, quand un contrôle a un parent, toutes ses propriétés prennent effet immédiatement. Il est par conséquent judicieux de les définir avant d'affecter sa propriété Parent pour assurer un effet instantané à une série d'affectations.

Ces initialisations exigent une référence à l'objet au sein duquel est instanciée une seconde classe : c'est Self qui la fournit.

En général, ce paramètre défini pour chaque méthode est sous-entendu. Ainsi vous pourriez tout à fait définir la méthode SetNom comme ceci :

procedure TAnimal.SetNom(AValue: string);
begin
if Self.fNom = AValue then
Exit;
Self.fNom := AValue;
end;

Cependant, en alourdissant inutilement l'écriture, vous risqueriez de créer une certaine confusion dans votre code. Ici Self est superflu puisque le champ fNom est sans ambiguïté possible encapsulé dans la classe TAnimal.

I-G - Notion de destructeur

Si l’oubli de créer l’instance d’une classe et son utilisation forcée provoquent une erreur fatale, s’abstenir de libérer l’instance d’une classe via un destructeur produira des fuites de mémoire, le système interdisant à d’autres processus d’accéder à des portions de mémoire qu’il pense encore réservées. Par conséquent, tout objet créé doit être détruit à la fin de son utilisation :

Nemo.Free; // libération des ressources de l’objet

À propos de destructeur, le lecteur attentif est en droit de se demander pourquoi il est baptisé Destroy alors que la méthode utilisée pour la destruction de l’objet est Free. En fait, Free vérifie que l’objet existe avant d’appeler Destroy, évitant ainsi de lever de nouveau une exception pour violation d’accès. Il en découle que l’on définit la méthode Destroy, mais que l’on appelle toujours la méthode Free.

Vous pouvez à présent terminer votre premier programme mettant en œuvre des classes créées par vos soins :

- définissez le gestionnaire OnCreate de la fiche principale :

procedure TMainForm.FormCreate(Sender: TObject);
// *** création de la fiche ***
begin
// on crée les instances et on donne un nom à chaque animal créé
Nemo := TAnimal.Create;
Nemo.Nom := 'Némo';
Rantanplan := TAnimal.Create;
Rantanplan.Nom := 'Rantanplan';
Minette := TAnimal.Create;
Minette.Nom := 'Minette';
// objet par défaut
UnAnimal := Nemo;
end;

- de la même manière, définissez le gestionnaire OnDestroy de cette fiche :

procedure TMainForm.FormDestroy(Sender: TObject);
// *** destruction de la fiche ***
begin
// on libère toutes les ressources
Minette.Free;
Rantanplan.Free;
Nemo.Free;
end;

Vous pouvez enfin tester votre application et constater que les animaux ainsi que les actions à effectuer sont reconnus !

I-H - Premiers gains de la POO

Que gagnez-vous à utiliser ce mécanisme apparemment plus lourd que celui auquel contraint la programmation structurée :

- en premier lieu, vous disposerez de briques pour la conception de vos propres créations. C’est exactement ce que vous faites quand vous utilisez un composant de Lazarus : une fiche est un objet sur lequel vous déposez d’autres objets comme des étiquettes, des éditeurs, des images, tous des instances de classes prédéfinies. Ces briques préfabriquées font évidemment gagner beaucoup de temps, mais ne sont pas nées d'une manœuvre magique !

- qui plus est, dans la mesure où la manière dont telle ou telle fonctionnalité est réalisée est indifférente, la modification de l’intérieur de la boîte n’influera en rien les autres programmes qui utiliseront la classe en cause (Cette remarque ne vaut évidemment que si la classe a été bien conçue dès le départ ! En particulier, si l’ajout de nouvelles fonctionnalités est toujours possible, en supprimer interdirait toute réelle rétrocompatibilité.) Dans l’exemple sur les animaux, vous pourriez fort bien décider que la méthode Dormir émette un bip : vous n’auriez qu’une ligne à ajouter au sein de cette méthode pour que tous les animaux bénéficient de ce nouveau comportement ;

- enfin, les données et les méthodes étant regroupées pour résoudre un micro-problème, la lisibilité et la maintenance de votre application s’en trouveront grandement facilitées. Circuler dans un projet de bonne dimension reviendra à examiner les interactions entre les briques dont il est constitué ou à étudier une brique particulière, au lieu de se perdre dans les méandres de bouts de codes entremêlés.

Vous allez voir ci-après que les gains sont bien supérieurs encore. À partir du petit exemple produit, vous pouvez déjà pressentir la puissance de la POO : imaginez avec quelle facilité vous pourriez ajouter un nouvel animal ! De plus, n’êtes-vous pas étonné par ces méthodes Create et Destroy surgies de nulle part ? D’où viennent-elles ? Sachant qu’en Pascal tout se déclare, comment se fait-il que vous ayez pu les utiliser sans avoir eu à les définir ?

II - Principes et techniques de la POO

II-A - L'encapsulation

Si vous reprenez l’interface de la classe TAnimal, fort de vos nouvelles connaissances, vous pourriez la commenter ainsi :

TAnimal = class // c’est bien une classe
strict private // indique que ce qui suit n’est pas visible à l’extérieur de la classe
fNom: string; // un champ de type chaîne
fASoif: Boolean; // deux champs booléens
fAFaim: Boolean;
procedure SetNom(AValue : string); // détermine la valeur d’un champ via une méthode
public // indique que ce qui suit est visible à l’extérieur de la classe
procedure Avancer; // des méthodes…
procedure Manger;
procedure Boire;
procedure Dormir;
// les propriétés permettant d’accéder aux champs
//  et/ou aux méthodes manipulant ces champs
property ASoif: Boolean read fASoif write fASoif;
property AFaim: Boolean read fAFaim write SetAFaim;
property Nom: string read fNom write SetNom;
end;

L’encapsulation est le concept fondamental de la POO. Il s’agit de protéger toutes les données au sein d’une classe : en général, même si Free Pascal laisse la liberté d’une manipulation directe des champs, seul l’accès à travers une méthode ou une propriété devrait être autorisé pour une encapsulation véritable.

Ainsi, aucun objet extérieur à une instance de la classe TAnimal ne connaîtra l’existence de fAFaim et ne pourra donc y accéder :

// Erreur : compilation refusée
MonObjet.AFaimAussi := MonAnimal.fAfaim;
// OK si ATresSoif est une propriété booléenne modifiable de AutreObjet
AutreObjet.ATresSoif := MonAnimal.AFaim;

Paradoxalement, cette contrainte est une bénédiction pour le programmeur qui peut pressentir la fiabilité de la classe qu’il utilise à la bonne encapsulation des données. Peut-être le traitement à l’intérieur de la classe changera-t-il, mais restera cette interface qui rendra inutile la compréhension de la mécanique interne.

II-B - Notion de portée

Le niveau d’encapsulation est déterminé par la portée du champ, de la propriété ou de la méthode. La portée répond à la question : qui est autorisé à voir cet élément et de ce fait à l’utiliser ?

Lazarus définit six niveaux de portée. Si l'on appelle élément un champ ou une méthode d'une classe, les règles suivantes s'appliqueront :

- strict private : l’élément n’est visible que par un élément de la même classe dans l'unité de la déclaration ;

- private : l’élément n’est visible que par un élément présent dans l'unité de la déclaration ;

- strict protected : l’élément n’est utilisable que par un descendant de la classe (donc une classe dérivée), qu’il soit dans l’unité de la classe ou dans une autre unité y faisant référence ;

- protected : l’élément n’est utilisable que par un descendant de la classe, qu’il soit dans l’unité de la classe ou dans une autre unité y faisant référence, ou par une autre classe présente dans l’unité de la classe ;

- public : l’élément est accessible partout et par tous ;

- published : l’élément est accessible partout et par tous, et comprend des informations particulières lui permettant de s’afficher dans l’inspecteur d’objets de Lazarus.

Ces sections sont toutes facultatives. En l’absence de précision, les éléments de l’interface sont de type public.

Le niveau d’encapsulation repose sur une règle bien admise qui est de ne montrer que ce qui est strictement nécessaire. Par conséquent, choisissez la plupart du temps le niveau d’encapsulation le plus élevé possible pour chaque élément. L’expérience vous aidera à faire les bons choix : l’erreur sera comme souvent formatrice !

Souvenez-vous tout d’abord que vous produisez des boîtes noires dans lesquelles l’utilisateur introduira des données pour en récupérer d’autres ou pour provoquer certains comportements comme un affichage, une impression, etc. Si vous autorisez la modification du cœur de votre classe et que vous la modifiez à votre tour, n’ayant a priori aucune idée du contexte de mise en œuvre de votre classe, vous êtes assuré de perturber les programmes qui l’auront utilisée.

Aidez-vous ensuite de ces quelques repères généraux :

- une section strict private abrite des champs et des méthodes qui servent d’outils de base (l’utilisateur de votre classe n’aura jamais besoin de se servir d’eux directement) ;

- une section private permet à d’autres classes de la même unité de partager des informations (elle est très fréquente pour des raisons historiques : la section strict private est apparue tardivement) ;

- les variantes de protected permettent surtout des redéfinitions de méthodes lors de la création de sous-classes ;

- la section public est la portée par défaut, qui n’a pas besoin de se faire connaître puisqu’elle s’offre à la première sollicitation venue !

- enfin, published sera un outil précieux lors de l’intégration de composants dans la palette de Lazarus.

La visibilité la plus élevée (public ou published) est toujours moins permissive qu’une variable globale : l’accès aux données ne peut s’effectuer qu’en spécifiant l’objet auquel elles appartiennent. Autrement dit, une forme de contrôle existe toujours à travers cette limitation intentionnelle. C’est dans le même esprit que les variables globales doivent être très peu nombreuses : visibles dans tout le programme, elles sont sources d’erreurs souvent difficiles à détecter et à corriger.

II-C - L'héritage

Jusqu’à présent, les classes vous ont sans doute semblé de simples enregistrements (record) aux capacités étendues : en plus de proposer une structure de données, elles fournissent les méthodes pour travailler sur ces données. Cependant, la notion de classe est bien plus puissante que ce qu’apporte l’encapsulation : il est aussi possible de dériver des sous-classes d’une classe existante qui hériteront de toutes les fonctionnalités de leur parent. Ce mécanisme s’appelle l’héritage.

Autrement dit, non seulement la classe dérivée saura exécuter les tâches qui lui sont propres, mais elle saura aussi, sans aucune ligne de code supplémentaire à écrire, exécuter les tâches que maîtrise son ancêtre.

Une classe donnée ne peut avoir qu’un unique parent, mais autant de descendants que nécessaire. L’ensemble forme une arborescence à la manière d’un arbre généalogique.

Encore plus fort : cet héritage se propage de génération en génération, la nouvelle classe héritant de son parent, de l’ancêtre de son parent, la chaîne ne s’interrompant qu’à la classe souche. Avec Lazarus, cette classe souche est TObject qui définit les comportements élémentaires que partagent toutes les classes.

Ainsi, la déclaration de TAnimal qui commençait par la ligne TAnimal = class est une forme elliptique de TAnimal = class(TObject) qui rend explicite la parenté des deux classes.

En particulier, vous trouverez dans TObject la solution au problème posé par l’apparente absence de définition de Create et de Destroy dans la classe TAnimal : c’est TObject qui les définit !

[Exemple PO_02]

Si vous manipuliez la classe TAnimal, vous pourriez avoir à travailler avec un ensemble de chiens et envisager alors de créer un descendant TChien aux propriétés et méthodes étendues.

En voici une définition possible que vous allez introduire dans l’unité animal, juste en dessous de la classe TAnimal :

TChien = class(TAnimal)
strict private
fBatard: Boolean;
procedure SetBatard(AValue: Boolean);
public
procedure Aboyer;
procedure RemuerLaQueue;
property Batard: Boolean read fBatard write SetBatard;
end;

La première ligne indique que la nouvelle classe descend de la classe TAnimal. Les autres lignes ajoutent des fonctionnalités (Aboyer et RemuerLaQueue) ou déclarent de nouvelles propriétés (Batard). La puissance de l’héritage s’exprimera par le fait qu’un objet de type TChien disposera des éléments que déclare sa classe, mais aussi de tout ce que proposent TAnimal et TObject, dans la limite de la portée qu’elles définissent.

Comme pour la préparation de sa classe ancêtre, placez le curseur sur une ligne quelconque de l’interface de la classe TChien et pressez Ctrl-Maj-C. Lazarus produit aussitôt les squelettes nécessaires aux définitions des nouvelles méthodes :

property Nom: string read fNom write SetNom;
end; // fin de la déclaration de TAnimal

{ TChien }

TChien = class(TAnimal)
strict private
fBatard: Boolean;
procedure SetBatard(AValue: Boolean);
public
procedure Aboyer;
procedure RemuerLaQueue;
property Batard: Boolean read fBatard write SetBatard;
end;

implementation

uses
Dialogs; // pour les boîtes de dialogue

{ TChien }

procedure TChien.SetBatard(AValue: Boolean);
begin
end;

procedure TChien.Aboyer;
begin
end;

procedure TChien.RemuerLaQueue;
begin
end;

{ TAnimal }

procedure TAnimal.SetNom(AValue: string); // […]

D’ores et déjà, les lignes de code suivantes seront compilées et exécutées sans souci :

Medor := TChien.Create; // on crée le chien Medor
Medor.Aboyer; // la méthode Aboyer est exécutée
Medor.Batard := True; // Medor n’est pas un chien de race
Medor.Manger; // il a hérité de son parent la capacité Manger
Medor.Free; // on libère la mémoire allouée

Comme les nouvelles méthodes ne font rien en l’état, complétez-les comme suit :

{ TChien }
procedure TChien.SetBatard(AValue: Boolean);
begin
fBatard := AValue;
end;

procedure TChien.Aboyer;
begin
MessageDlg(Nom + ' aboie...', mtInformation, [mbOK], 0);
end;

procedure TChien.RemuerLaQueue;
begin
MessageDlg(Nom + ' remue la queue...', mtInformation, [mbOK], 0);
end;

De même, modifiez légèrement l’unité main afin qu’elle prenne en compte cette nouvelle classe avec l’objet Rantanplan :

//[…]
procedure rbRantanplanClick(Sender: TObject);
private
{ private declarations }
Nemo, Minette, UnAnimal : TAnimal;
Rantanplan: TChien; // <= changement
public
{ public declarations }
end;

var
MainForm: TMainForm;

implementation

{$R *.lfm}

{ TMainForm }

procedure TMainForm.FormCreate(Sender: TObject);
// *** création de la fiche ***
begin
// on crée les instances et on donne un nom à l'animal créé
Nemo := TAnimal.Create;
Nemo.Nom := 'Némo';
Rantanplan := TChien.Create; // <= changement
Rantanplan.Nom := 'Rantanplan';
Minette := TAnimal.Create;

II-D - Le polymorphisme

Lancez votre programme et essayez différents choix. Vous remarquez que ce programme et celui qui n’avait pas défini TChien se comportent exactement de la même manière !

Que notre nouvelle application ne prenne pas en compte les nouvelles caractéristiques de la classe TChien n’a rien de surprenant puisque nous ne lui avons pas demandé de le faire, mais que notre Rantanplan se comporte comme un TAnimal peut paraître déroutant.

Notez que vous n’avez pas changé l’affectation de Rantanplan à UnAnimal qui est de type TAnimal :

procedure TMainForm.rbRantanplanClick(Sender: TObject);
// *** l'animal est Rantanplan ***
begin
UnAnimal := Rantanplan;
end;

De même, si vous reprenez la partie de code qui correspond à un choix dans TListBox, vous constaterez qu’elle traite sans sourciller le cas où UnAnimal est un TChien :

procedure TMainForm.lbActionClick(Sender: TObject);
// *** choix d'une action ***
begin
case lbAction.ItemIndex of
0: UnAnimal.Avancer;
1: UnAnimal.Manger;
2: UnAnimal.Boire;
3: UnAnimal.Dormir;
end;
end;

Ce comportement apparemment étrange tient au fait que tout objet de type TChien est aussi de type TAnimal. En héritant de toutes les propriétés et méthodes publiques de son ancêtre, une classe peut légitimement occuper sa place si elle le souhaite : Rantanplan est donc un objet TChien ou un objet TAnimal ou, bien sûr, un objet TObject. C’est ce qu’on appelle le polymorphisme, qui est une conséquence directe de l’héritage : un objet d’une classe donnée peut prendre la forme de ses ancêtres.

Grâce au polymorphisme, l’affectation suivante est correcte :

UnAnimal := Rantanplan;

L’objet Rantanplan remplit toutes les conditions pour satisfaire la variable UnAnimal : en tant que descendant de TAnimal, il possède toutes les propriétés et méthodes à même de compléter ce qu’attend UnAnimal.

La réciproque n’est pas vraie et l’affectation suivante déclenchera dès la compilation une erreur, avec un message « types incompatibles » :

Rantanplan := UnAnimal;

En effet, UnAnimal est incapable de renseigner les trois apports de la classe TChien : les méthodes Aboyer, RemuerLaQueue et la propriété Batard resteraient indéterminées. Ce comportement est identique à celui attendu dans le monde réel : vous savez qu’un chien est toujours un animal, mais rien ne vous assure qu’un animal soit forcément un chien !

Pour les curieux : certains d’entre vous auront remarqué que de nombreux gestionnaires d’événements comme OnClick comprennent un paramètre Sender de type TObject. Comme TObject est l’ancêtre de toutes les classes, grâce au polymorphisme, n’importe quel objet est accepté en paramètre. Ces gestionnaires s’adaptent donc à tous les objets qui pourraient faire appel à eux ! Élégant, non ?

II-E - Les opérateurs Is et As

Évidemment, il serait intéressant d’exploiter les nouvelles caractéristiques de la classe TChien. Mais comment faire puisque notre objet de type TChien est pris pour un objet de type TAnimal ?

Il existe heureusement deux opérateurs qui permettent facilement de préciser ce qui est attendu :

- is vérifie qu’un objet est bien du type d’une classe déterminée et renvoie une valeur booléenne (True ou False) ;

- as force un objet à prendre la forme d’une classe déterminée : si cette transformation (appelée transtypage) est impossible du fait de l’incompatibilité des types, une erreur est déclenchée.

Par conséquent, vous pourriez écrire ceci avec is :

if (Rantanplan is TChien) then // Rantanplan est-il un chien ?
Result := 'Il s’’agit d’’un chien’
else
Result := 'Ce n’’est pas un chien.’;
// […]
Result := (Minette is TChien); // faux
Result := (Nemo is TObject); // vrai

Et ceci avec as :

(Rantaplan as TChien).Aboyer; // inutile mais correct
Rantanplan.Aboyer // équivalent du précédent
(Nemo as TChien).Dormir; // erreur : Nemo n’est pas de type TChien
(UnAnimal as TChien).Manger; // correct pour Rantaplan mais pas pour les autres

Le déclenchement possible d’une exception avec as conduit à l’accompagner, dès qu'il y a plusieurs possibilités d'objets à traiter, d’un test préalable avec is :

if (UnAnimal is TChien) then // l’objet est-il du type voulu ?
(UnAnimal as TChien).Aboyer; // si oui, transtypage avant d’exécuter la méthode
// le transtypage en dur est aussi possible puisque UnAnimal est certainement de type TChien
// TChien(UnAnimal). Aboyer est par conséquent correct

[Exemple PO_03]

Pour ce qui est du projet en cours, reprenez le programme et modifiez-le ainsi :

- ajoutez « Aboyer » à la liste des actions possibles dans le composant lbAction de type TListBox :

- modifiez la méthode OnClick de lbAction dans l’unité main.pas :

procedure TmainForm.lbActionClick(Sender: Tobject);
// *** choix d’une action ***
begin
case lbAction.ItemIndex of
0: UnAnimal.Avancer;
1: UnAnimal.Manger;
2: UnAnimal.Boire;
3: UnAnimal.Dormir;
4: if (UnAnimal is TChien) then
(UnAnimal as TChien).Aboyer
else
MessageDlg(UnAnimal.Nom + ' ne sait pas aboyer…’, mtError, [mbOK], 0);
end;
end;

La traduction en langage humain de cette modification est presque évidente : si l’objet UnAnimal est du type TChien, alors forcer cet animal à prendre la forme d’un chien et à aboyer, sinon signaler que cet animal ne sait pas aboyer.

Les opérateurs is et as sont les outils indispensables à qui veut profiter au mieux du polymorphisme. Grâce à eux, les objets manipulés adoptent la forme désirée.

III - Bilan

Ce premier voyage initiatique à travers la Programmation Orientée Objet s'achève ici. Dans ce tutoriel, vous aurez appris à :

- comprendre ce qu’est la Programmation Orientée Objet à travers les notions de classe, d’encapsulation, de portée, d’héritage, de polymorphisme et de transtypage ;

- définir et utiliser les classes, les objets, les constructeurs, les destructeurs, les champs et les méthodes ;

- définir les propriétés.

Vous voilà désormais prêt à vous aventurer un peu plus loin en approfondissant les notions de méthodes et de propriétés, ainsi qu'en découvrant les magiques auxiliaires de classes !

Je remercie Alcaltîz, Roland Chastain, ThWilliam, Jipété et BeanzMaster pour leur relecture technique, ainsi que jacques_jean et Winjerome pour les corrections.