PureBasic et la Programmation Orientée Objet

Deuxième Implémentation

Dans notre première implémentation, nombres de concepts ont été traduits d'une manière plus ou moins étendue.
Nous allons voir maintenant comment on peut améliorer cette implémentation grâce à l'utilisation de l'instruction Interface.

Notion d'interface Objet :
Nous avons vu que la notion d'encapsulation avait comme but premier de rendre visible à l'utilisateur qu'une partie de l'objet.
La partie visible du contenu est appelée interface, l'autre partie cachée est appelée implémentation.

L'interface d'un objet est donc la seule porte d'entrée/sortie dont dispose l'utilisateur pour agir sur un objet.

C'est le rôle que l'on va donner dans notre utilisation de l'instruction Interface.

L'instruction Interface va donc nous permettre de regrouper sous un même Nom, tout ou partie des méthodes d'un objet que l'utilisateur aura le droit de manipuler.

Instanciation et Constructeur d'Objet
Vouloir utiliser une interface c'est d'abord se munir :

  1. d'une Interface décrivant les méthodes que l'on veut utiliser,
  2. d'une Structure décrivant les pointeurs d'adresses des fonctions correspondantes,
  3. d'une table des méthodes: variable initialisée issue de cette structure.

L'étape 1, consistant à d'écrire l'Interface d'un objet, n'est pas compliquée. Il suffit de nommer les méthodes.

Les étapes 2 et 3 sont liées. Or dans notre approche objet, nous disposons déjà de la Structure adaptée: c'est celle qui décrit la Classe d'un objet.
En effet, l'Interface et la Classe d'un objet se ressemblent: Tous deux comportent des pointeurs de fonctions.
Simplement, l'instruction Interface ne contient pas les attributs de la Classe mais seulement tout ou partie des méthodes de la Classe.

Il est donc tout à fait possible de se servir de la Classe d'un objet pour initialiser l'interface. Cette démarche est d'ailleurs des plus naturelles. Rappelons que l'interface est la partie visible de la Classe d'un objet, il est donc normal que l'interface soit déterminée par la Classe.

Voyons comment procéder.
Reprenons la Classe Rectangle2 munie des deux méthodes : Dessiner() et Effacer()

Sa Classe est la suivante

Structure Rectangle2
*Dessiner
*Effacer
x1.l
x2.l
y1.l
y2.l
EndStructure

Procedure Dessiner_Rectangle(*this.Rectangle2)

EndProcedure

Procedure Effacer_Rectangle(*this.Rectangle2)

EndProcedure

Définissons maintenant l'interface suivante:

Interface Rectangle
Dessiner()
Effacer()
EndInterface

Comme on veut obliger l'utilisateur à passer par l'Interface, il n'est plus question de créer un objet directement à partir de la Classe Rectangle2.

L'objet sera donc créée en écrivant :

Rect.Rectangle

au lieu de Rect.Rectangle2

Cependant, il ne faut pas oublier de relier l'Interface à la Classe.
Pour cela il faut initialiser l'objet Rect et il est conseillé de le faire lors de la déclaration de l'objet.
Correction faite, la bonne instruction pour déclarer l'objet via l'interface est la suivante :

Rect.Rectangle = New_Rect(0, 10, 0, 20)

New_Rect() est une fonction qui réalise l'opération d'initialisation.
Ce que l'on sait déjà d'elle, c'est qu'elle retourne comme valeur l'adresse mémoire contenant les adresses des fonctions utilisées par l'interface.

Voici maintenant le corps de la fonction New_Rect()

Procedure New_Rect(x1.l, x2.l, y1.l, y2.l)
*Rect.Rectangle2 = AllocateMemory(SizeOf(Rectangle2))

*Rect\Dessiner = @Dessiner_Rectangle()
*Rect\Effacer = @Effacer_Rectangle()

*Rect\x1 = x1
*Rect\x2 = x2
*Rect\y1 = y1
*Rect\y2 = y2

ProcedureReturn *Rect
EndProcedure

Cette fonction alloue une zone mémoire de la taille de la Classe de l'objet.
Elle initialise ensuite les méthodes puis les attributs de l'objet.
Elle se termine en retournant l'adresse de cette zone mémoire.
Comme on trouve au début de cette zone mémoire d'abord les adresses des fonctions Dessiner_Rectangle() et Effacer_Rectangle(), on initialise effectivement l'interface.

Pour accéder aux méthodes de l'objet Rect, il suffit d'écrire :

Rect\Dessiner()
Rect\Effacer()

On vérifie bien que :

  • la Classe Rectangle2 permet d'initialiser l'interface de l'objet.
  • Rect, déclaré via l'interface, est un objet de la Classe Rectangle2 pouvant utiliser les méthodes Dessiner() et Effacer()

Nous avons donc réalisé, via l'Interface et la fonction New_Rect(), l'instanciation d'un objet Rect de la Classe Rectangle2.
La fonction New_Rect() est appelée Constructeur de l'objet de Classe Rectangle2.

Toutes les implémentations des Méthodes (blocs Procedure/EndProcedure) doivent comporter comme premier argument le pointeur *this de l'objet sur lequel on doit appliquer la fonction.
A l'opposé, l'argument *this ne doit plus apparaît au niveau de l'Interface. En effet, comme l'instruction nous permet d'écrire Rect\Dessiner(), on sait que la méthode Dessiner() est celle de l'objet Rect: Il n'y a pas d'ambiguïté. Tout se passe comme si l'objet Rect était "conscient" de son état.

Le constructeur pourrait recevoir comme paramètres supplémentaires, les adresses des fonctions implémentant les méthodes. Il n'en est rien ici car on connait l'implémentation des méthodes: c'est celle de la classe. Par contre on ne connait pas l'état initial que l'utilisateur veut donner à l'objet. Il se peut donc que le constructeur comporte des paramètres pour l'initialisation des attributs.
C'est le cas ici pour New_Rect() demandant en entrée les coordonnées (x1,y1) et (x2,y2) des points diamétralement opposés du rectangle.

Initialisation d'Objet
Nous avons vu que le constructeur, après avoir alloué la place mémoire nécessaire à l'objet, initialise les différents membres de l'objet (méthodes et attributs).
On peut isoler cette opération dans une procédure à part, que le constructeur appellera.
Cette précaution permet de distinguer l'allocation mémoire de l'initialisation de l'objet. Ceci sera très utile pour mener à bien par la suite le concept d'héritage, car une seule allocation de mémoire suffit, mais plusieurs initialisations seront nécessaires.

Cependant nous séparerons l'initialisation des méthodes et celle des attributs.
En effet, l'implémentation des méthodes dépend de la classe, alors que l'initialisation des attributs dépend de l'objet lui-même (voir remarque précédente ).

Dans notre exemple, nous écrirons les deux procédures suivantes :

Procedure Init_Mthds_Rect(*Rect.Rectangle2)
*Rect\Dessiner = @Dessiner_Rectangle()
*Rect\Effacer = @Effacer_Rectangle()
EndProcedure

Procedure Init_Mbers_Rect(*Rect.Rectangle2, x1.l, x2.l, y1.l, y2.l)
*Rect\x1 = x1
*Rect\x2 = x2
*Rect\y1 = y1
*Rect\y2 = y2
EndProcedure

et le Constructeur devient:

Procedure New_Rect(x1.l, x2.l, y1.l, y2.l)
*Rect = AllocateMemory(SizeOf(Rectangle2))
Init_Mthds_Rect(*Rect)
Init_Mbers_Rect(*Rect, x1, x2, y1, y2)
ProcedureReturn *Rect
EndProcedure

Destructeur d'Objet
On associe toujours à un constructeur d'objet, son opposé : le destructeur d'objet.
Lors de la construction d'un objet, une zone mémoire a était allouée pour stocker les définitions des méthodes et des attributs.
Quant un objet n'est plus utile, il ne faut pas oublier de le détruire pour libérer la mémoire.
Ce processus se fait en utilisant une fonction appelée Destructeur d'objet.

Dans notre exemple d'objet de la Classe Rectangle2, le destructeur d'objet s'écrira :

Procedure Free_Rect(*Rect)
FreeMemory(*Rect)
EndProcedure

et s'utilisera, comme suite:

Free_Rect(Rect2)


On peut voir le Destructeur d'objet comme une méthode de l'objet. Mais pour éviter d'alourdir l'objet et garder une homogénéité avec le Constructeur, nous avons préféré le voir comme une fonction de la Classe.

Détruire un objet par son Destructeur, signifie que l'on libère la place mémoire contenant les informations de l'objet mais en aucun cas on ne détruit l'infrastructure de l'objet.
Aussi, dans notre exemple, après avoir fait:
    Free_Rect(Rect2)
on peut toujours réutiliser Rect2 sans préciser à nouveau son type:
    Rect2 = New_Rect(0, 10, 0, 20)
    Rect2\Dessiner()
En effet, lorsque l'on réalise l'instanciation d'un objet, comme suite :
   Rect2.Rectangle
on crée un objet Rect2 dont la durée de vie est assujettie aux mêmes règles que celles des variables car Rect2 est d'abord une variable : C'est une variable structurée continuant les pointeurs de fonctions des méthodes de l'objet.(voir aussi le rappel qui suit)

Petit rappel : La durée de vie d'une variable est liée à la durée de vie de la partie du programme où elle est déclarée :

  • Si la variable est déclarée à l'intérieur d'une fonction, sa durée de vie sera liée à celle de la fonction, c'est à dire égale au temps d'utilisation de la fonction.
  • Si la variable est déclarée en dehors de toute fonction, c'est à dire dans le corps principal du programme, sa durée de vie est liée à celle du programme

Allocations Mémoire

A chaque nouvelle instanciation, le constructeur doit allouer dynamiquement une place mémoire de la taille des informations décrivant l'objet.
Pour cela, le Constructeur peut utiliser la commande AllocateMemory(), associée à la commande FreeMemory() pour le Destructeur.
Mais cela peut être une toute autre commande d'allocation dynamique de mémoire.
Sous OS Windows, on peut passer directement par les API par exemple.

En standard, PureBasic propose les listes chaînées qui permettent aussi d'allouer dynamiquement de la mémoire.

Encapsulation
Imaginons maintenant que l'on ne veuille donner à l'utilisateur seulement accès à la méthode Dessiner() de la Classe Rectangle2. On commencera par définir l'interface désirée :

Interface Rectangle
Dessiner()
EndInterface

L'instanciation du nouvel objet reste la même:

Rect.Rectangle = New_Rect()

avec

Procedure Init_Mthds_Rect(*Rect.Rectangle2)
*Rect\Dessiner = @Dessiner_Rectangle()
*Rect\Effacer = @Effacer_Rectangle()
EndProcedure

Procedure Init_Mbers_Rect(*Rect.Rectangle2, x1.l, x2.l, y1.l, y2.l)
*Rect\x1 = x1
*Rect\x2 = x2
*Rect\y1 = y1
*Rect\y2 = y2
EndProcedure

Procedure New_Rect(x1.l, x2.l, y1.l, y2.l)
*Rect = AllocateMemory(SizeOf(Rectangle2))
Init_Mthds_Rect(*Rect)
Init_Mbers_Rect(*Rect, x1, x2, y1, y2)
ProcedureReturn *Rect
EndProcedure

car en effet, la première adresse de fonction est bien celle de la méthode Dessiner().

Maintenant, imaginons que l'on veuille donner à l'utilisateur seulement accès à la méthode Effacer(). On commencera par définir l'interface suivante:

Interface Rectangle
Effacer()
EndInterface

Par contre l'instanciation du nouvel objet ne peut utiliser le constructeur New_Rect().
Dans le cas contraire, le résultat serait identique au cas précédent.

Il faut donc créer un nouveau constructeur capable de retourner l'adresse de fonction adaptée.

En voici un :

Procedure Init_Mthds_Rect2(*Rect.Rectangle2)
*Rect\Dessiner = @Effacer_Rectangle()
*Rect\Effacer = @Dessiner_Rectangle()
EndProcedure

Procedure Init_Mbers_Rect(*Rect.Rectangle2, x1.l, x2.l, y1.l, y2.l)
*Rect\x1 = x1
*Rect\x2 = x2
*Rect\y1 = y1
*Rect\y2 = y2
EndProcedure

Procedure New_Rect2(x1.l, x2.l, y1.l, y2.l)
*Rect = AllocateMemory(SizeOf(Rectangle2))
Init_Mthds_Rect2(*Rect)
Init_Mbers_Rect(*Rect, x1, x2, y1, y2)
ProcedureReturn *Rect
EndProcedure

Vous constatez qu'il a suffit d'inverser les adresses de fonction dans l'initialisation des méthodes de la Classe.
Certes, ce n'est pas très élégant de devoir affecter au champ Dessin de la Structure Rectangle2 l'adresse d'une toute autre fonction.
Si cela permet de conserver la même Structure, celle de la Classe, cela souligne aussi une chose :
Les noms des pointeurs de fonctions nous intéressent moins que leurs valeurs !
Pour gommer ce faux problème, il suffit de renommer les pointeurs de la Classe comme suite :

Structure Rectangle2
*Methode1
*Methode2
x1.l
x2.l
y1.l
y2.l
EndStructure

C'est l'Interface et le Constructeur qui donnent un sens à ces pointeurs :

  • en leur donnant un nom (rôle de l'interface)
  • en leur affectant les adresses de fonctions adéquates (rôle du constructeur)
Malgré cette disposition concernent les noms des pointeurs de fonction, il reste bien plus pratique de conserver un nom explicite si l'on ne compte pas cacher les méthodes (ce qui est le plus courant). Cela permet de faire évoluer une Classe Mère sans retoucher à la numérotation des pointeurs des Classes Filles.

Héritage
Comme lors de notre première implémentation du concept d'héritage, nous allons profiter de la qualité qu'ont à la fois les instructions Structure et Interface d'être étendues grâce au mot-clé Extends.

Ainsi, nous passerons de la Classe Rectangle1 possédant une seule méthode Dessiner()…

Interface

Interface Rect1
Dessiner()
EndInterface
Classe

Structure Rectangle1
*Methode1
x1.l
x2.l
y1.l
y2.l
EndStructure

Procedure Dessiner_Rectangle(*this.Rectangle1)

EndProcedure

Procedure Init_Mthds_Rect1(*Rect.Rectangle1)
*Rect\Methode1 = @Dessiner_Rectangle()
EndProcedure

Constructeur

Procedure Init_Mbers_Rect1(*Rect.Rectangle1, x1.l, x2.l, y1.l, y2.l)
*Rect\x1 = x1
*Rect\x2 = x2
*Rect\y1 = y1
*Rect\y2 = y2
EndProcedure

Procedure New_Rect1(x1.l, x2.l, y1.l, y2.l)
*Rect = AllocateMemory(SizeOf(Rectangle1))
Init_Mthds_Rect1(*Rect)
Init_Mbers_Rect1(*Rect, x1, x2, y1, y2)
ProcedureReturn *Rect
EndProcedure

…à la Classe Rectangle2, possédant 2 méthodes : Dessiner() et Effacer() en écrivant :

Interface

Interface Rect2 Extends Rect1
Effacer()
EndInterface
Classe

Structure Rectangle2 Extends Rectangle1
*Methode2
EndStructure

Procedure Effacer_Rectangle(*this.Rectangle2)

EndProcedure

Procedure Init_Mthds_Rect2(*Rect.Rectangle2)
Init_Mthds_Rect1(*Rect)
*Rect\Methode2 = @Effacer_Rectangle()
EndProcedure

Constructeur Procedure Init_Mbers_Rect2(*Rect.Rectangle2, x1.l, x2.l, y1.l, y2.l)
Init_Mbers_Rect1(*Rect, x1, x2, y1, y2)
EndProcedure

Procedure New_Rect2(x1.l, x2.l, y1.l, y2.l)
*Rect = AllocateMemory(SizeOf(Rectangle2))
Init_Mthds_Rect2(*Rect)
Init_Mbers_Rect2(*Rect, x1, x2, y1, y2)
ProcedureReturn *Rect
EndProcedur

Accomplir un héritage consiste non seulement à étendre l'Interface et la Classe mais aussi à adapter l'initialisation des méthodes et des attributs.
Les deux procédures Init_Mthds_Rect2() et Init_Mbers_Rect2() font appel respectivement à l'initialisation des méthodes et à l'initialisation des attributs de la Classe Rectangle1 ( Init_Mthds_Rect1() et Init_Mbers_Rect1() ) et non au constructeur New_Rect1().
En effet, il n'est pas question d'instancier un objet de la Classe Mère (Rectangle1) pour construire un objet de la Classe Fille (Rectangle2).
Il est question simplement d'hériter des méthodes et des attributs, ce à quoi contribue l'emploi des initialisations de la Classe Mère dans la Classe Fille.

D'autre part, on vérifie bien qu'en modifiant la Classe Mère (en ajoutant une méthode ou une variable), la Classe Fille bénéficie instantanément des changements.

L'héritage est-il pour autant correct? Non, car dans l'état actuel, il ne permet pas à l'objet de la Classe Fille (Rectangle2) d'utiliser la nouvelle méthode Effacer() !
Ceci tout simplement car le pointeur de fonction *Methode2 ne se trouve pas directement à la suite du pointeur de fonction *Methode1.

Si on explicite la Structure de la Classe Rectangle2, on a :

Structure Rectangle2
*Methode1
x1.l
x2.l
y1.l
y2.l
*Methode2
EndStructure

au lieu de disposer de la Structure ci-dessous, autorisant une initialisation correcte de l'interface:

Structure Rectangle2
*Methode1
*Methode2
x1.l
x2.l
y1.l
y2.l
EndStructure

Rappelez-vous qu'il faut des adresses de fonction qui se suivent et qui soient ordonnées à l'image de l'Interface ().
On résout ce problème en regroupant dans une structure spécifique les méthodes entre-elles !
Il suffit alors que la Structure de la Classe garde un pointeur sur cette nouvelle structure comme le montre l'exemple suivant :

Interface

Interface Rect1
Dessiner()
EndInterface
Classe

Structure Rectangle1
*Methodes
x1.l
x2.l
y1.l
y2.l
EndStructure

Procedure Dessiner_Rectangle(*this.Rectangle1)

EndProcedure

Structure Mthds_Rect1
*Methode1
EndStructure

Procedure Init_Mthds_Rect1(*Mthds.Mthds_Rect1)
*Mthds\Methode1 = @Dessiner_Rectangle()
EndProcedure

Mthds_Rect1. Mthds_Rect1
Init_Mthds_Rect1(@Mthds_Rect1)

Constructeur

Procedure Init_Mbers_Rect1(*Rect.Rectangle1, x1.l, x2.l, y1.l, y2.l)
*Rect\x1 = x1
*Rect\x2 = x2
*Rect\y1 = y1
*Rect\y2 = y2
EndProcedure

Procedure New_Rect1(x1.l, x2.l, y1.l, y2.l)
Shared
Mthds_Rect1
*Rect.Rectangle1 = AllocateMemory(SizeOf(Rectangle1))
*Rect\Methodes = @Mthds_Rect1
Init_Mbers_Rect1(*Rect, x1, x2, y1, y3)
ProcedureReturn *Rect
EndProcedure

La structure Mthds_Rect1 décrit tous les pointeurs de fonction des méthodes de la Classe.
S'en suit la déclaration de la variable Mthds_Rect1 de type Mthds_Rect1 ainsi que son initialisation grace à Init_Mthds_Rect1().

La variable Mthds_Rect1 est appelée la table des méthodes de la class car elle contient l'ensemble des adresses des méthodes de la class.


Cet ensemble constitue la description complète des méthodes de la Classe.

La structure Rectangle1, comporte maintenant un pointeur *Methodes, initialisé par le constructeur en donnant l'adresse de la variable Mthds_Rect1.

L'expression
    Mthds_Rect1.Mthds_Rect1
    Init_Mthds_Rect1(@Mthds_Rect1)
peut etre condensée en
    Init_Mthds_Rect1(@Mthds_Rect1.Mthds_Rect1)

L'héritage est alors possible car en étendant la Structure Methd_Rect1 en une nouvelle Methd_Rect2, les adresses de fonction vont se suivre:

Interface

Interface Rect2 Extends Rect1
Effacer()
EndInterface
Classe

Structure Rectangle2 Extends Rectangle1
EndStructure

Procedure Effacer_Rectangle(*this.Rectangle2)

EndProcedure

Structure Mthds_Rect2 Extends Mthds_Rect1
*Methode2
EndStructure

Procedure Init_Mthds_Rect2(*Mthds.Mthds_Rect2)
Init_Mthds_Rect1(*Mthds)
*Mthds\Methode2 = @Effacer_Rectangle()
EndProcedure

Mthds_Rect2. Mthds_Rect2
Init_Mthds_Rect2(@Mthds_Rect2)

Constructeur

Procedure Init_Mbers_Rect2(*Rect.Rectangle2 , x1.l, x2.l, y1.l, y2.l)
Init_Mbers_Rect1(*Rect, x1, x2, y1, y2)
EndProcedure

Procedure New_Rect2(x1.l, x2.l, y1.l, y2.l)
Shared
Mthds_Rect2
*Rect.Rectangle2 = AllocateMemory(SizeOf(Rectangle2))
*Rect\Methodes = @Mthds_Rect2
Init_Mbers_Rect2(*Rect, x1, x2, y1, y2)
ProcedureReturn *Rect
EndProcedure

Dans cet exemple, la Structure Rectangle2 est vide, ce qui n'est pas gênant en soit.
Deux raisons à cela :

  • D'abord le pointeur *Methodes n'a besoin d'exister qu'une seule fois et ceci dans la Classe Mère.
  • Ensuite, nous n'avons pas souhaitez ajouter d'attributs supplémentaires, auquel cas elle aurait dû les contenir.

Le fait d'avoir externalisé l'initialisation des méthodes hors du constructeur combiné à des pointeurs de fonctions disponiblent dans une variable fixe a trois avantages:

  • Les pointeurs de fonction des méthodes de la Classe sont initialisés une fois pour toute et non plus à chaque instanciation d'un objet
  • Les objets instanciés ne disposent plus que d'un pointeur vers les pointeurs des méthodes: le gain en place mémoire est substantiel.
  • Comme tous les objets pointent vers les mêmes pointeurs de fonction, cela garantit un comportement identique des objets de même Classe.

Accesseur et Modifieur d'Objet
En passant par l'Interface, il n'est possible de manipuler que des méthodes de l'objet.
L'interface encapsule donc entièrement les attributs des objets, c'est à dire qu'elle les cache.
Pour accéder aux attributs, soit pour les lires, soit pour les modifier, il faut donc disposer de méthodes spécifiques et les mettre à disposition de l'utilisateur.
Les méthodes qui permettent de lire les attributs d'un objet sont appelées les Accesseur de l'objet.
Les méthodes qui permettent de modifier les attributs d'un objet sont appelées les Modifieurs de l'objet.

Dans notre exemple de Classe Rectangle1, si nous voulons lire la valeur de l'attribut var2, on créera l'Accesseur suivant:

Procedure Get_var2(*this.Rectangle1)
ProcedureReturn *this\var2
EndProcedure

De même, pour modifier la valeur de l'attribut var2, on écrira le Modifieur suivant

Procedure Set_var2(*this.Rectangle1, valeur)
*this\var2 = valeur
EndProcedure

Comme les Accesseurs et les Modifieurs n'existent que pour permettre à l'utilisateur de modifier tout ou partie des attributs d'un objet, ils sont obligatoirement présents dans l'interface.

Voir l'annexe Optimisation du tutorial pour étudier de quelle manière on peut optimiser les performances des accesseurs et des modifieurs lors de l'exécution

 

Sommaire

[1-2-3-4-5-6-7-8-9]
Retour en haut de page