Office et les modules de classes

Emmanuel Tissot

Cet article vous propose de découvrir les fonctionnalités des modules de classe du langage VBA des applications Office.

- Titre : Office et les modules de classes

- Auteur : Emmanuel Tissot

- Parution : 21 novembre 2008

- 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 ® 2008 . 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. Droits de diffusion permanents accordés à Developpez LLC.

Sommaire

- I. Préambule

- II. Généralités

- II-A. Classe et instance

- II-B. Créer un module de classe

- II-C. Contenu d'un module de classe

- II-D. Evènements d'un module de classe

- II-E. Utiliser un module de classe

- II-F. Intérêt des modules de classes

- II-F-1. Utiliser des variables évoluées

- II-F-2. Cacher la complexité

- II-F-3. Produire un code fiable, lisible, réutilisable, auto documenté

- II-G. Cas particuliers

- II-G-1. Les modules de documents

- II-G-2. Les formulaires (Userform)

- III. Les gestionnaires d'évènements

- III-A. Capter un évènement

- III-A-1. Associer une variable à un évènement

- III-A-2. Activer les procédures évènementielles d'une variable

- III-B. Procédures évènementielles commune à plusieurs objets

- III-B-1. Utiliser une collection d'instance

- III-B-2. Objets et évènements dynamiques

- III-B-3. Réutilisabilité

- III-C. Conclusion

- IV. Les objets personnalisés

- IV-A. Les propriétés

- IV-A-1. Définition

- IV-A-2. Créer une propriété

- IV-A-3. Avantages des procédures Property

- IV-A-4. Propriété renvoyant un objet

- IV-A-5. Propriété de type Variant

- IV-A-6. Propriété renvoyant un tableau

- IV-A-6-a. Exposition d'un tableau typé

- IV-A-6-b. Exposition d'un Variant

- IV-A-7. Remarque

- IV-B. Les méthodes

- IV-B-1. Définition

- IV-B-2. Créer une méthode

- IV-B-3. Contenu d'une méthode

- IV-C. Les évènements

- IV-C-1. Définition

- IV-C-2. Déclaration

- IV-C-3. Déclenchement

- IV-C-4. Utilisation

- IV-C-5. Annulation

- IV-C-6. Erreurs et évènements

- IV-C-7. Désactiver les évènements d'une classe

- IV-D. Les collections

- IV-D-1. Introduction

- IV-D-2. Adapter l'objet Collection

- IV-D-3. La méthode Add

- IV-D-4. La propriété Name

- IV-D-5. Propriétés communes à chaque instance

- IV-D-6. Gérer les évènements au niveau de la collection

- IV-D-7. Itération avec For Each

- IV-D-8. Objets orphelins

- IV-D-9. L'évènement Terminate

- IV-D-10. Résumé

- IV-E. Les bibliothèques

- IV-E-1. Création

- IV-E-2. Avantages

- IV-E-3. Evolution

- IV-E-4. Utilisation

- IV-F. Programmation orientée objets ?

- IV-F-1. Héritage et polymorphisme

- IV-F-2. Implémenter une classe abstraite

- IV-F-3. Implémenter une classe non abstraite

- V. Conclusion

I - Préambule

Cet article se propose d'expliquer aussi clairement et complètement que possible ce que sont les modules de classe dans le cadre de VBA, langage de programmation utilisé notamment par les applications Microsoft Office. La première partie exposera quelques généralités, des exemples permettront ensuite de se familiariser avec le fonctionnement de ces modules, enfin la troisième partie approfondira l'aspect théorique afin de montrer l'étendue des possibilités.

Le thème de cet article veut qu'il ne s'adresse pas aux débutants, je suppose donc que vous maîtrisez les concepts de base de VBA (portée des variables, appels de procédure) ainsi que des notions plus avancées (programmation évènementielle, gestion des erreurs) bref que vous avez un minimum d'expérience.

La rédaction de cet article m'a permis d'approfondir mes connaissances sur ce thème, j'espère qu'il en sera de même pour vous et je vous souhaite une bonne lecture.

II - Généralités

II-A - Classe et instance

Un module de classe est un module qui permet de définir un nouvel objet. Mais alors qu'est ce qu'un objet ? Pour faire court disons simplement que c'est un ensemble indissociable de données et de procédures permettant de manipuler ces données.

Une classe (comprenez un module de classe) est donc un ensemble de propriétés, de méthodes et d'évènements qui définissent respectivement les caractéristiques, les capacités et le comportement des objets qui en sont issus. Pour donner une définition imagée une classe est un modèle, un moule, un plan, un concept, une matrice.

Une instance est la représentation en mémoire d'un objet définit à partir d'une classe, il s'agit donc de la concrétisation d'une abstraction. Bien évidemment plusieurs instances de la même classe peuvent exister simultanément, elles demeureront indépendantes l'une de l'autre.

II-B - Créer un module de classe

Dans l'éditeur VBE faites simplement Insertion > Module de classe. Affichez la fenêtre propriétés (F4) et modifiez le nom par défaut pour donner à votre objet un nom plus explicite en rapport avec sa nature.

Dans cette même fenêtre apparaît la propriété Instancing qui accepte deux valeurs, Private par défaut, et PublicNotCreatable. Cela devrait vous donner une idée de son usage mais nous y reviendrons plus tard.

II-C - Contenu d'un module de classe

Un module de classe peut contenir (avec quelques restrictions) tout ce que peut contenir un module standard, l'inverse n'est pas vrai.

Pour les déclarations:

- Déclarations de variables scalaires

- Déclarations de variables tableau (1)

- Déclarations de constantes (1)

- Instructions Enum définissant un groupe de constantes

- Variables objets dotées d'évènements déclarées avec WithEvents (2)

- Instructions Event pour définir des évènements personnalisés (2)

- Instructions Type pour définir un type de donnée personnalisé (1)

- Instructions Declare pour établir des références externes (ex API Windows) (1)

- Instructions Implements pour redéfinir l'interface d'une autre classe (2)

Pour les procédures:

- Procédures Sub, Function et Property

- Procédures évènementielles liées à des variables déclarées avec le mot-clé WithEvents (2)

- Procédures évènementielles Class_Initialize et Class_Terminate (2)

(1) Ces éléments sont obligatoirement privés.
(2) Ces éléments sont propres aux modules de classes et ne peuvent figurer dans un module standard.

II-D - Evènements d'un module de classe

Un module de classe est doté de deux évènements particuliers, Class_Initialize et Class_Terminate. Les noms sont assez explicite sur leur raison d'être et devraient vous rappeler UserForm_Initialize et UserForm_Terminate.

Class_Initialize se déclenche quand vous créez une nouvelle instance de la classe avec le mot-clé New. Il est employé pour fixer les valeurs initiales des variables du module.

Class_Terminate se déclenche quand l'instance de la classe est détruite, c'est-à-dire quand cette instance n'est plus désignée par aucune variable. Il sert à faire le ménage avant la destruction effective de l'instance. Par exemple si une classe génère des fichiers temporaires, il est bon de prévoir leur destruction dans cet évènement afin d'éviter une prolifération inutile.

En pratique l'évènement peut être déclenché en affectant la valeur Nothing à toutes les variables désignant la même instance. En admettant que nous ayons deux variables désignant le même objet:

Set MonObjet1 = Nothing
Set MonObjet2 = Nothing

L'évènement Terminate ne sera déclenché que par la deuxième instruction. En d'autres termes affecter Nothing à une variable ne détruit pas l'instance qu'elle désignait mais seulement la référence à cette instance, tant qu'une autre variable continue de désigner cette même instance l'évènement Terminate ne se déclenche pas.

Il peut aussi se déclencher implicitement lorsque toutes les variables désignant une instance cessent d'exister, par exemple lorsque une variable locale à une procédure est détruite à la fin de cette procédure, ou quand un Userform est déchargé avec une instruction Unload. Notez enfin que l'évènement Terminate ne se déclenche pas si le programme rencontre une instruction End.

II-E - Utiliser un module de classe

Il faut tout d'abord déclarer une variable qui représentera l'objet, et ensuite initialiser cette variable.

Dim MonObjet As NomClasse
Set MonObjet = New NomClasse

Il est possible de réunir ces deux instructions pour n'en former qu'une seule.

Dim MonObjet As New NomClasse

Dans ce cas de figure l'instance est créée à chaque utilisation de la variable sauf si elle est déjà initialisée. En plus de rendre le programme moins lisible ce comportement empêche de vérifier au cours du programme si la variable est initialisée.

If MonObjet Is Nothing Then
MsgBox "Variable non initialisée."
Else
MsgBox "Variable initialisée."
End If

Avec la deuxième option le test ci-dessus affichera systématiquement la deuxième MsgBox puisque si la variable n'est pas initialisée - donc égale à Nothing - elle le sera avant que la comparaison ne soit effectuée. La première option semble donc préférable. La variable se manipule ensuite comme n'importe quelle autre variable objet en utilisant la syntaxe classique.

Objet.Propriete = Valeur            'Affecte une valeur à une propriété

Valeur = Objet.Propriete            'Récupère la valeur d'une propriété

Objet.Methode Argument              'Exécute une méthode de l'objet

Resultat = Objet.Methode(Argument)  'Lit une valeur renvoyée par une méthode de l'objet

II-F - Intérêt des modules de classes

Ecrire un module de classe c'est écrire du code qui permet d'écrire du code, ce n'est donc pas un finalité en soi mais plutôt une étape intermédiaire du processus de développement. La plupart des projets se passent très bien de module de classe, ce qui n'empêche pas de réaliser des applications parfaitement fiables et adéquates. On peut donc estimer que les modules de classes n'ont rien d'indispensable et ne sont qu'une manière parmi d'autres d'arriver à ses fins. Ils ne sont en fait qu'une couche de programmation supplémentaire qui s'intercale entre les bibliothèques d'objets (VBA, Office, ADO...) utilisées par un projet et le code applicatif qui est écrit pour réaliser ce projet.

II-F-1 - Utiliser des variables évoluées

Si vous avez déjà ressenti les limites des variables classiques qui ne savent que stocker une information, vous avez peut être eu l'occasion d'utiliser des variables personnalisées définies avec une instruction Type. Cette instruction vous permet d'avoir facilement et en permanence accès à plusieurs informations connexes, c'est déjà beaucoup mieux qu'une simple variable. Ces variables personnalisées restent toutefois limitées en ce sens qu'elles n'offrent aucun contrôle (en dehors du type de donnée) sur le contenu de leurs différents champs. Les objets seront l'étape suivante de l'évolution de vos variables. Non seulement ils associeront une quantité quelconque d'informations variées et de procédures permettant de contrôler, manipuler, ou restituer ces informations mais en plus ils pourront envoyer des signaux à vos applications par le biais d'évènements ou d'erreurs personnalisées. Les objets sont donc des variables intelligentes conçues pour simplifier la vie du développeur.

II-F-2 - Cacher la complexité

En utilisant les objets fournis par une application telle que Word ou Excel vous ne vous demandez pas comment ils fonctionnent. Par exemple, la classe Workbook propose une méthode SaveAs qui permet d'enregistrer le classeur sous un autre nom. Cela implique d'accéder au disque dur, de vérifier si l'espace disponible est suffisant, mais aussi que le répertoire défini existe, et certainement d'autres choses. Toutes ces vérifications complexes sont cachées derrière un simple appel à une méthode qui résout l'ensemble des problèmes une fois pour toutes, dés l'instant que le comportement de cette méthode est reconnu fiable il devient inutile de réinventer un processus à chaque fois que l'on veut effectuer cette tache élémentaire, et ce même si on ignore comment le résultat est obtenu. Les modules de classes doivent offrir la même garantie et se comporter comme des "boites noires", leur contenu n'a plus d'importance, la seule chose qui compte est qu'ils assurent les services pour lesquels ils ont été conçus afin que le développeur qui les utilise puisse se concentrer sur son objectif essentiel, réaliser son application.

II-F-3 - Produire un code fiable, lisible, réutilisable, auto documenté

Pour définir un nouvel objet, vous devrez définir ses propriétés, méthodes et évènements. Cela demandera au préalable un minimum d'analyse et rallongera d'autant le temps de développement mais il s'agit aussi d'un investissement à long terme, l'objectif ultime étant de produire un code plus compréhensible, facile à maintenir, auto documenté et surtout réutilisable, ce dernier aspect étant particulièrement important. Songez que si un problème est résolu au moyen d'un module de classe il n'y a rien de plus facile que de réutiliser cette classe dans un autre projet pour résoudre un problème similaire. Cela n'est toutefois possible que si le problème est exprimé sous une forme générique, abstraite, indépendante du contexte de l'application pour laquelle la classe a été développée initialement. Enfin, produire des classes réutilisables force une tolérance d'erreur égale à zéro et donc une phase de test rigoureuse, ce qui tend à augmenter la fiabilité globale du programme.

II-G - Cas particuliers

II-G-1 - Les modules de documents

Dans certains fichiers Office, le projet VBA de ces fichiers intègre par défaut des modules de classes. ThisDocument dans Word ou ThisWorkbook dans Excel, sont des modules de classe. A ce titre ils fournissent toutes les fonctionnalités prévues par ce type de module (ils sont en mesure d'héberger des procédures évènementielles), et en subissent toutes les contraintes (impossible d'y déclarer un tableau Public par exemple). Néanmoins ils ne peuvent pas être instanciés car ils ne définissent pas un objet, ils sont simplement une extension d'un objet.

La raison d'être de ces modules est de fournir un raccourci de programmation pour l'exploitation des objets auxquels ils sont rattachés. Grâce à eux nous pouvons nous passer d'écrire un module de classe, de déclarer une variable et de l'instancier. Il reste néanmoins possible de s'en passer et c'est même utile dans certains cas. Par exemple dans un projet Excel, le regroupement des procédures évènementielles des feuilles de calcul dans un module de classe, permet d'une part d'exporter les feuilles dans un autre classeur sans exporter le code qui y est attaché, et d'autre part préserve le code en cas de suppression accidentelle d'une feuille, cela sans polluer le module ThisWorkbook qui par nature peut jouer ce rôle.

II-G-2 - Les formulaires (Userform)

Tout comme les modules ci-dessus, un Userform fournit ses propres procédures évènementielles, ainsi que celles des objets qu'il contient (TexBox etc.) et celles de variables objets déclarées avec WithEvents. Il se rapproche un peu plus d'un module de classe normal quand on s'aperçoit qu'il est possible d'instancier un Userform, c'est-à-dire d'utiliser simultanément plusieurs objets basés sur un seul modèle.

Dim X As Userform1, Y As Userform1

Set X = New Userform1
Set Y = New Userform1

Ces instructions créent deux instances du même formulaire, sans les afficher. On peut ensuite manipuler ces deux instances indépendamment l'une de l'autre avant de les afficher tour à tour.

X.Caption = "Formulaire X"
Y.Caption = "Formulaire Y"

X.Show
Y.Show

En pratique, on a rarement besoin d'utiliser simultanément plusieurs exemplaires d'un même formulaire, et de fait un formulaire s'utilise le plus souvent comme un objet qu'il est inutile d'instancier, via son nom de classe.

Userform1.Caption = "Légende du formulaire 1"
Userform1.Show

Lorsque vous n'effectuez pas l'instanciation du formulaire de manière explicite c'est VBA qui s'en charge à votre place, conséquence pratique dans l'exemple ci-dessus la procédure Userform_Initialize est déclenchée par la première instruction faisant référence au formulaire. En résumé un Userform c'est un module de classe associé à une interface graphique personnalisable.

III - Les gestionnaires d'évènements

Cette partie va présenter des exemples simples de modules de classes. Si d'un point de vue technique ces modules sont bels et bien de nouveaux objets, ils n'en sont pas vraiment d'un point de vue conceptuel si l'on considère que leur objectif n'est pas de définir une structure de donnée associée à des méthodes de manipulation mais plutôt d'exploiter les évènements de certains objets dans le cadre d'une application particulière.

III-A - Capter un évènement

Lorsque un objet déclenche un évènement, c'est pour permettre à l'application de réagir à cet évènement. Cette réaction passe par une procédure évènementielle associée à cet objet et rédigée dans un module de classe. L'exemple ci-dessous montre comment utiliser les évènements de l'application Excel.

III-A-1 - Associer une variable à un évènement

Dans un nouveau projet créez un nouveau module de classe que vous appellerez AppEvents. Ecrivez ensuite dans ce module la déclaration suivante.

Private WithEvents xlApp As Application

Le mot-clé WithEvents sert à indiquer que nous allons attacher des procédures évènementielles à la variable xlApp. On ne peut l'employer qu'avec des objets dotés d'un jeu d'évènements et vous pouvez remarquer que l'éditeur VBE ne propose qu'une liste restreinte d'objets répondants à ce critère, cette liste est directement dépendante de l'application avec laquelle vous travaillez et des bibliothèques que vous utilisez.

Une fois cette déclaration effectuée les procédures évènementielles de la classe Application deviennent disponibles dans la liste de droite du module lorsque vous sélectionnez xlApp dans la liste de gauche. Nous pouvons donc maintenant écrire une procédure qui se déclenchera à chaque fois qu'un classeur sera ouvert.

Private Sub xlApp_WorkbookOpen(ByVal Wb As Workbook)
MsgBox Wb.FullName
End Sub

Cette procédure ne fait qu'afficher une MsgBox, elle pourrait servir à réorganiser les fenêtres, afficher ou masquer des barres d'outils, tenir un historique des fichiers ouverts etc.

III-A-2 - Activer les procédures évènementielles d'une variable

La seule chose à faire pour rendre opérationnelle une procédure évènementielle attachée à une variable est d'initialiser cette variable. Si vous avez remarqué que xlApp est déclarée Private vous conclurez rapidement que le seul moyen de l'initialiser consiste à utiliser la procédure Class_Initialize.

Private Sub Class_Initialize()
Set xlApp = Application
End Sub

Il n'y a rien d'autre à ajouter à ce module de classe, il est tout à fait fonctionnel en l'état. Il ne reste maintenant qu'à créer dans le projet une instance de la classe. Pour ce faire déclarons une variable de type AppEvents dans un module standard.

Dim ThisApplication As AppEvents

Les règles générales relatives à la portée et à la durée de vie des variables s'appliquent bien entendu à notre variable ThisApplication. Pour qu'elle puisse perdurer nous devons donc la déclarer au niveau module, mais rien n'interdirait d'effectuer cette déclaration dans une procédure si nous n'avions qu'un besoin temporaire de surveiller ces évènements. Enfin, on initialise cette variable dans une procédure des plus basiques.

Public Sub Activer_Evenements_Application()
Set ThisApplication = New AppEvents
End Sub

L'instanciation du module entraîne via sa procédure Class_Initialize l'initialisation de sa variable xlApp et par voie de conséquence les procédures évènementielles de cette variable deviennent opérationnelles. Elles le resteront tant que la variable ThisApplication contiendra une référence à une instance du module AppEvents.

La réception d'un évènement est donc finalement assez triviale et peut être mise en œuvre en appliquant le schéma suivant.

Dans un module de classe:

- Déclaration d'une variable avec le mot-clé WithEvents

- Ecriture des procédures évènementielles liées à cette variable

Ailleurs dans le projet:

- Déclaration d'une variable dont le type correspond au nom du module de classe

- Affectation à cette variable d'une instance de la classe avec le mot-clé New

- Connexion de la variable WithEvents de l'instance avec un objet existant

III-B - Procédures évènementielles commune à plusieurs objets

Nous venons de voir comment intercepter les évènements d'un objet au moyen d'un module de classe, nous allons maintenant affecter la même procédure évènementielle à plusieurs objets identiques, en l'occurrence une série de TextBox placés sur un Userform.

III-B-1 - Utiliser une collection d'instance

Dans un nouveau projet créez donc un nouveau formulaire et placez-y quelques TextBox. Notre but sera de n'autoriser dans chacun de ces TextBox que la saisie de nombre entier. Ce problème pourrait se résoudre en dupliquant pour chaque TextBox la procédure évènementielle suivante.

Private Sub TextBox1_KeyPress(ByVal KeyAscii As MSForms.ReturnInteger)
If KeyAscii < 48 Or KeyAscii > 57 Then KeyAscii = 0
End Sub

Bien évidemment cela est particulièrement lourd, fastidieux et peu évolutif. En cas d'ajout d'un TextBox supplémentaire il faut systématiquement ajouter une procédure, et si les TextBox sont renommés par la suite les procédures devront être réécrites. Voyons maintenant comment l'utilisation d'un module de classe permet de résoudre ces problèmes et d'obtenir d'autres avantages. Dans un module de classe que nous appellerons pour l'occasion NumBox, écrivons le code suivant.

Public WithEvents TargetBox As MSForms.TextBox

Private Sub TargetBox_KeyPress(ByVal KeyAscii As MSForms.ReturnInteger)
If KeyAscii < 48 Or KeyAscii > 57 Then KeyAscii = 0
End Sub

La déclaration d'une variable publique (TargetBox) équivaut à définir une propriété, nous y reviendrons.

Nous venons d'attacher notre procédure évènementielle à une variable de type TextBox, nous allons maintenant faire en sorte que cette variable pointe sur chaque TextBox du formulaire en créant dans celui-ci autant d'instances du module - et donc de la variable - que nécessaire au moyen d'une collection.

Dim NumBoxes As Collection             'Stocke les références à chaque instance

Private Sub UserForm_Initialize()
Dim Ctl As MSForms.Control
Dim MyNumBox As NumBox
Set NumBoxes = New Collection
For Each Ctl In Me.Controls
If TypeOf Ctl Is MSForms.TextBox Then
Set MyNumBox = New NumBox     'Crée une nouvelle instance
Set MyNumBox.TargetBox = Ctl  'Connecte la variable à l'objet
NumBoxes.Add MyNumBox         'Ajoute l'instance à la collection
End If
Next
End Sub

Comme vous pouvez le voir la démarche est exactement la même que dans l'exemple précédent, la seule différence étant que la classe est instanciée plusieurs fois afin de surveiller plusieurs objets. On s'aperçoit immédiatement que la quantité de code produite est bien moins importante qu'avec une approche sans module de classe ce qui implique une maintenance plus aisée. D'autre part ce qui fonctionne pour le formulaire A fonctionnera aussi pour le formulaire B, le code placé dans le module de classe est donc réutilisable à loisir.

III-B-2 - Objets et évènements dynamiques

Un autre bénéfice à utiliser un module de classe plutôt qu'une approche statique est qu'il devient possible d'affecter des évènements dynamiquement à des objets qui peuvent eux-mêmes être créés de manière dynamique. En voici l'illustration avec 3 TextBox ajoutés à l'exécution.

Dim NumBoxes As Collection                         'Stocke les références à chaque instance

Private Sub UserForm_Initialize()
Dim Ctl As MSForms.Control
Dim MyNumBox As NumBox
Dim i As Long
Set NumBoxes = New Collection
For i = 1 To 3
Set Ctl = Me.Controls.Add("Forms.TextBox.1") 'Crée un TextBox
Ctl.Top = (i - 1) * 30 + 10                  'Positionne le TextBox
Ctl.Left = 10
Set MyNumBox = New NumBox                    'Crée une nouvelle instance
Set MyNumBox.TargetBox = Ctl                 'Connecte la variable à l'objet
NumBoxes.Add MyNumBox                        'Ajoute l'instance à la collection
Next
End Sub

Aussi aisément qu'on peut affecter des procédures évènementielles à des objets on peut déconnecter ces procédures d'un objet particulier en détruisant l'instance du module qui pointe sur cet objet.

Private Sub CommandButton1_Click()
MyNumBoxes.Remove 1
End Sub

Et pour une déconnexion globale il suffirait de détruire la collection.

Private Sub CommandButton1_Click()
Set MyNumBoxes = Nothing
End Sub

III-B-3 - Réutilisabilité

Oublions un instant les objets et évènements dynamiques et supposons simplement que notre formulaire contienne trois TextBox auxquels on veuille associer une valeur plafond et un affichage en rouge lorsque l'utilisateur indique une valeur qui dépasse ce plafond. Voici à quoi pourrait ressembler notre module.

Public WithEvents TargetBox As MSForms.TextBox

Private Sub TargetBox_KeyPress(ByVal KeyAscii As MSForms.ReturnInteger)
If KeyAscii < 48 Or KeyAscii > 57 Then KeyAscii = 0
End Sub

Private Sub TargetBox_Change() 'Evènement Change de la variable
Dim OverMaxValue As Boolean
With TargetBox
Select Case .Name
Case "TextBox1"
If .Value > 100 Then OverMaxValue = True
Case "TextBox2"
If .Value > 200 Then OverMaxValue = True
Case "TextBox3"
If .Value > 300 Then OverMaxValue = True
End Select
If OverMaxValue Then
.ForeColor = vbRed
Else
.ForeColor = vbBlack
End If
End With
End Sub

Ceci est certes fonctionnel, simple à écrire et à comprendre, mais va à l'encontre de la philosophie des classes personnalisés. En effet une modification du formulaire (ajout d'un nouveau champ par exemple) nécessitera une modification du module de classe. De même un simple changement des valeurs plafonds associées à chacun des champs est impossible en cours d'exécution les valeurs étant ici codées en dur, tout comme le nom des champs eux-mêmes.

De tels changements seraient sans doute faciles à mettre en œuvre mais cela révèle néanmoins une liaison bien trop forte entre le module de classe et l'application, autrement dit un manque d'abstraction. Quant à la réutilisabilité du module elle est ici proche de zéro tout simplement parce qu'il contient des informations qui sont du domaine de l'application, il serait donc impossible de l'utiliser en l'état pour un formulaire ayant des besoins différents.

Pour préserver les avantages qu'une approche utilisant un module de classe est censée fournir (souplesse, réutilisabilité) il faut donc séparer strictement les taches entre la classe et l'application.

Pour l'application:

- Décider quels sont les champs à surveiller

- Définir une valeur maximale pour chacun d'eux

Pour la classe NumBox:

- Détecter les évènements relatifs à un champ de saisie

- Evaluer la saisie et modifier l'affichage si nécessaire

Il apparaît maintenant clairement que le module de classe n'a besoin que de deux informations:

- Un objet TextBox

- Une valeur numérique

Notre module de classe corrigé pourrait donc ressembler à ceci.

'Déclarations au niveau module
Public MaxValue As Long
Public WithEvents TargetBox As MSForms.TextBox

'Evènements de la variable TargetBox
Private Sub TargetBox_KeyPress(ByVal KeyAscii As MSForms.ReturnInteger)
If KeyAscii < 48 Or KeyAscii > 57 Then KeyAscii = 0
End Sub

Private Sub TargetBox_Change()
On Error Resume Next 'NB: La gestion d'erreur est volontairement minimaliste
If MaxValue > 0 Then
With TargetBox
If CLng(.Value) > MaxValue Then
.ForeColor = vbRed
Else
.ForeColor = vbBlack
End If
End With
End If
End Sub

La procédure TargetBox_Change ne fonctionnera que si l'application (comprenez le formulaire) initialise cette propriété avec une valeur strictement positive. Du coté de l'application, nous sommes libres de créer - statiquement ou dynamiquement - autant de champs de saisies que nous le voulons et de définir pour chacun d'eux une valeur plafond à n'importe quel moment.

Dim NumBoxes As Collection                         'Stocke les références à chaque instance

Private Sub UserForm_Initialize()
Dim Ctl As MSForms.Control
Dim MyNumBox As NumBox
Dim i As Long
Set NumBoxes = New Collection
For i = 1 To 3
Set Ctl = Me.Controls.Add("Forms.TextBox.1") 'Crée un TextBox
Ctl.Top = (i - 1) * 30 + 10                  'Positionne le TextBox
Ctl.Left = 10
Set MyNumBox = New NumBox                    'Crée une nouvelle instance
Set MyNumBox.TargetBox = Ctl                 'Connecte la variable à l'objet
MyNumBox.MaxValue = i * 100                  'Définit une valeur plafond
NumBoxes.Add MyNumBox                        'Ajoute l'instance à la collection
Next
End Sub

En élevant le niveau d'abstraction de notre classe nous l'avons donc fait passer du statut de simple gestionnaire d'évènement dont l'usage était limité à un seul formulaire à celui d'objet personnalisé utilisable sans aucune modification pour n'importe quel formulaire de n'importe quel projet.

III-C - Conclusion

Avant de commencer la lecture du chapitre suivant, voici ce que vous devriez retenir de celui qui s'achève :

- Une analyse préalable est impérative pour ne pas mélanger le code de l'application et celui du module.

- Il est obligatoire d'instancier une classe avant de l'utiliser.

- La création d'une instance se fait avec le mot-clé New, cela déclenche la procédure Class_Initialize.

- Il n'existe pas de constructeur personnalisé, on est donc souvent amené à prévoir une procédure Initialize.

- Tout ce qui peut être déclaré privé devrait l'être, cela augment la fiabilité.

- Une instance n'est détruite que lorsqu'elle n'est plus désignée par aucune variable, à ce moment seulement la procédure Class_Terminate se déclenche.

- Dés lors qu'un objet dispose d'évènements, on peut les intercepter via une variable déclarée avec WithEvents.

IV - Les objets personnalisés

Maintenant que nous sommes familiarisés avec les modules de classes et leurs principes élémentaire de fonctionnement nous allons étudier leurs différentes facettes d'un point de vue plus théorique afin de comprendre comment il est possible de créer de nouveaux objets.

Dans cette section j'utilise les conventions suivantes :

- Toutes les procédures (Sub, Function et Property) étant publiques et par souci de lisibilité je ne précise pas leurs portées sauf si elles doivent être privées.

- Le nom des variables servant à conserver la valeur d'une propriété est composé du préfixe cp (Class Property) suivi du nom de la propriété correspondante.

IV-A - Les propriétés

IV-A-1 - Définition

C'est une caractéristique d'un objet c'est-à-dire un élément permettant de le décrire. Si cet élément est modifiable on parle alors de propriété en lecture écriture, dans le cas contraire il s'agit d'une propriété en lecture seule.

IV-A-2 - Créer une propriété

Le moyen le plus simple, que nous avons déjà utilisé, consiste à déclarer une variable publique au niveau du module.

Public Value As Double

La variable étant publique, elle sera librement accessible à l'application aussi bien en lecture qu'en écriture. Une autre façon de procéder consiste à utiliser une paire de procédures Property associée à une variable privée de niveau module.

Private cpValue As Double

Property Get Value() As Double
Value = cpValue         'Renvoi la valeur actuelle
End Property

Property Let Value(ByVal NewValue As Double)
cpValue = NewValue      'Mise à jour de la valeur
End Property

Le type de donnée retournée par une procédure Property Get doit correspondre au type de donnée de l'argument reçu par la procédure Property Let (ou Set) portant le même nom. Le rôle des procédures Property est de servir de relais entre la variable privée du module et l'application utilisant le module. Property Get sert à la lecture, Property Let à l'écriture, l'argument NewValue représentant la partie droite de l'instruction d'affectation.

Les arguments des procédures Property sont toujours passés par valeur et ce même si vous précisez le mot-clé ByRef, cette particularité permet de préserver avec certitude les variables de l'application des agissements du module. Les arguments des procédures Property peuvent - et doivent - donc être considérés comme des variables locales à la procédure.

Quelque soit l'option choisie pour définir la propriété l'application accède à la variable avec les mêmes instructions, ce qui implique la possibilité de transformer une variable publique en couple de procédures Property et vice-versa sans avoir à modifier l'application.

Dim MonObjet As NomClasse

Set MonObjet = New NomClasse

MonObjet.Value = 1            'Pour l'écriture
MsgBox MonObjet.Value         'Pour la lecture

IV-A-3 - Avantages des procédures Property

Les procédures Property sont bien plus souples et fiables qu'une simple variable publique. En effet une variable publique ne peut définir qu'une propriété en lecture écriture alors qu'avec les procédures Property il est possible de définir une propriété en lecture seule en n'écrivant pas de procédure Property Let (ou Set).

L'argument NewValue étant passé par valeur le module ne peut pas modifier la variable qui lui est transmise par l'application et de l'autre coté du miroir, la variable cpValue étant privée l'application ne pourra la modifier qu'en passant par la procédure Property Let. L'application reste donc maîtresse de ses propres variables et le module reste maître des siennes, cette étanchéité se traduit concrètement par une plus grande fiabilité.

Corollaire de ce qui précède, les procédures Property permettent de contrôler efficacement les valeurs qui sont assignées à la variable. En admettant que notre propriété Value représente un prix, nous ne pouvons admettre que sa valeur soit inférieure à zéro. Il nous suffit de modifier la procédure Property Let pour empêcher une assignation de valeur incorrecte.

Property Let Value(NewValue As Double)
If NewValue >= 0 Then
cpValue = NewValue                                          'Mise à jour
Else
Err.Raise vbObjectError + 1, , "Valeur négative interdite"  'Erreur
End If
End Property

Le même raisonnement s'applique à l'intérieur du module. Si d'autres procédures du module doivent modifier une variable de propriété il est préférable - même si ce n'est pas une règle absolue - d'appeler la procédure Property Let de cette propriété plutôt que d'accéder directement à la variable. Pour reprendre l'exemple ci-dessus un accès direct à la variable cpValue est susceptible de lui affecter une valeur qui ne serait pas conforme à la règle contenue dans la procédure Property Let, en passant systématiquement par la procédure Property Let une telle erreur ne peut pas se produire.

De même la modification d'une propriété peut nécessiter d'autres actions. Pour reprendre l'exemple de NumBox, lorsque le formulaire modifie la propriété MaxValue il est impératif de réévaluer la valeur courante du TextBox pour en synchroniser l'affichage, ce qui est possible si la propriété est implanté sous forme de procédures Property ne l'est pas avec une simple variable publique.

Private cpMaxValue As Long

Property Let MaxValue(NewMaxValue As Long)
cpMaxValue = NewMaxValue
TargetBox_Change              'Synchronisation de l'affichage
End Property

Enfin et ce n'est pas le moins important, les procédures Property sont évolutives. Si pour une raison quelconque vous décidez qu'elles sont inadaptées, vous pouvez les modifier pour ajouter ou supprimer des contrôles, cela sera sans incidence sur l'application qui utilise le module. Une variable publique n'offre évidemment pas cette possibilité.

IV-A-4 - Propriété renvoyant un objet

Il est tout à fait possible de définir une propriété de type objet, il faut pour cela utiliser une procédure Property Set au lieu de Property Let, et utiliser le mot-clé Set dans la procédure Property Get.

Private cpMyRange As Range                'Stocke une plage de cellule

Property Get MyRange() As Range
Set MyRange = cpMyRange                'Renvoi la plage stockée
End Property

Property Set MyRange(NewRange As Range)
Set cpMyRange = NewRange               'Définit une nouvelle plage à stocker
End Property

IV-A-5 - Propriété de type Variant

Les propriétés de type Variant pouvant contenir aussi bien des données intrinsèques (Double, String etc.) que des objets il faut prévoir les deux cas.

Private cpVariantProperty As Variant

Pour la lecture de la propriété il est nécessaire de tester le type de donnée de la variable avant de la renvoyer.

Property Get VariantProperty() As Variant
If IsObject(cpVariantProperty) Then
Set VariantProperty = cpVariantProperty
Else
VariantProperty = cpVariantProperty
End If
End Property

Pour l'écriture il suffit de prévoir simultanément les procédures Property Let et Property Set, la procédure effectivement appelée sera déterminée par l'instruction appelante.

MonObjet.VariantProperty = 1                 'Appellera Property Let
Set MonObjet.VariantProperty = AnyObject     'Appellera Property Set

Property Let VariantProperty(NewVariantProperty As Variant)
clsVariantProperty = NewVariantProperty
End Property

Property Set VariantProperty(NewVariantProperty As Variant)
Set clsVariantProperty = NewVariantProperty
End Property

Si l'usage de propriété de type Variant est possible il est toutefois déconseillé. D'une part parce que les variables de type Variant sont moins performantes que les variables typées, mais surtout parce qu'il est plus difficile d'en contrôler le contenu. Bien entendu si la variable n'est pas destiné à contenir des objets on se passera de Property Set, et inversement de Property Let si on ne veut stocker que des objets dans notre variable.

IV-A-6 - Propriété renvoyant un tableau

Pour exposer un tableau, deux stratégies sont envisageables, exposer un véritable tableau ou exposer une propriété de type Variant contenant un tableau. Par souci de simplification on admettra que la propriété définit un tableau unidimensionnel de type Long et d'indice inférieur égal à zéro et que le paramètre recu par Property Let est conforme à ces caractéristiques. Concrètement, il serait bien entendu nécessaire de vérifier ces caractéristiques avant de procéder à un quelconque traitement.

IV-A-6-a - Exposition d'un tableau typé

Private cpList() As Long

Property Get List() As Long()
List = cpList
End Property

Property Let List(ByRef NewList() As Long)
'Vérification des caractéristiques de NewList (base et dimensions)
cpList = NewList
End Property

Notez que les dimensions du tableau ne sont pas fixées à la déclaration, ceci parce qu'il est impossible de procéder à des affectations entre tableaux de taille fixe, et que Property Get se termine par une paire de parenthèses indiquant qu'elle renvoie un tableau.

Bien qu'il soit déclaré ByRef (une déclaration ByVal engendre une erreur de compilation), l'argument NewList se comporte comme s'il était déclaré ByVal. La modification de ses éléments, de ses dimensions avec ReDim ou une éventuelle réinitialisation avec l'instruction Erase serait donc sans effet sur le tableau transmis par l'application.

Sub Test_Propriete_Tableau_1()
Dim MonObjet As NomClasse, TabLong() As Long, i As Long
Set MonObjet = New NomClasse
ReDim TabLong(0 To 3)
For i = 0 To 3
TabLong(i) = i
Next
MonObjet.List = TabLong       'Affectation d'un tableau à la propriété
Erase TabLong
MsgBox UBound(MonObjet.List)  'Affiche l'indice supérieur
TabLong = MonObjet.List       'Récupère le tableau stocké par la propriété
End Sub

En lecture l'application reste libre de récupérer la valeur de la propriété dans un tableau dynamique ou dans un Variant, les fonctions UBound et LBound sont applicables à la propriété Values.

En écriture, l'application doit fournir en paramètre un tableau dynamique de même type que la propriété. Toute affectation d'un tableau de taille fixe ou d'un autre type de donnée provoque une erreur de compilation même s'il s'agit d'un type compatible (Integer au lieu de Long par exemple). L'usage des fonctions Array ou Split, qui renvoient un Variant, est donc impossible.

Cette solution est peu satisfaisante lorsqu'il s'agit d'accéder aux éléments du tableau. En effet, si en lecture une syntaxe limite intuitive est fonctionnelle, la même syntaxe est inopérante en écriture.

MsgBox MonObjet.List()(1)           'Renvoie la valeur de l'élément 1
MonObjet.List()(1) = 10             'L'élément 1 n'est pas modifié

En conséquence si l'application veut modifier un élément du tableau il lui faut d'abord récupérer l'intégralité du tableau dans une variable temporaire, modifier ensuite l'élément dans cette variable et enfin réaffecter cette variable à la propriété.

TabLong = MonObjet.List             'Récupère l'intégralité du tableau
TabLong(1) = 10                     'Modifie un élément
MonObjet.List = TabLong             'Affecte l'intégralité du tableau à la propriété

Ceci n'étant ni convivial ni gage de performance, on utilisera donc une deuxième propriété dotée d'un argument identifiant l'élément auquel on accède.

Property Get ListItem(Index As Long) As Long
ListItem = cpList(Index)
End Property

Property Let ListItem(Index As Long, NewListItem As Long)
cpList(Index) = NewListItem
End Property

Ces procédures sont évidemment susceptibles de générer une erreur d'exécution si Index ne correspond pas à un indice valide. Enfin, si un contrôle sur la validité des éléments du tableau est nécessaire, il suffit de prévoir une simple fonction privée contenant les règles de validation.

Property Let List(ByRef NewList() As Long)
Dim i As Long
'Vérification des caractéristiques de NewList (base et dimensions)
For i = 0 To UBound(NewList)                                               'Validation
If Not CheckItem(NewList(i)) Then
Err.Raise vbObjectError + 1, , "Valeur incorrecte à l'indice " & i   'Erreur
End If
Next
cpList = NewList                                                           'Affectation
End Property

Property Let ListItem(Index As Long, NewListItem As Long)
If CheckItem(NewListItem) Then                                             'Validation
cpList(Index) = NewListItem                                             'Affectation
Else
Err.Raise vbObjectError + 1, , "Valeur incorrecte à l'indice " & Index  'Erreur
End If
End Property

Private Function CheckItem(Item As Long) As Boolean
CheckItem = Item >= 0                  'Refuse les valeurs négatives
End Function

Avantage principal de cette solution, on ne procède nulle part à un quelconque contrôle sur le type des données du tableau reçu en paramètre, cela facilite et accélère le traitement qui reste donc relativement simple. Du point de vue de l'application les contraintes d'usage peuvent néanmoins sembler trop rigides, ce qui nous amène à envisager la deuxième solution.

IV-A-6-b - Exposition d'un Variant

Exposer une propriété sous forme d'un Variant ne signifie pas pour autant que cette propriété soit destinée à contenir tout les types de données possibles. Par souci de cohérence avec l'exemple précédent on conservera donc en interne un tableau typé.

Private cpList() As Long

Property Get List(Optional Index As Variant) As Variant
If IsMissing(Index) Then
List = cpList                  'Renvoie le tableau
Else
List = cpList(CLng(Index))     'Renvoie un élément
End If
End Property

Pour la lecture, la propriété se voit dotée d'un argument optionnel de type Variant permettant de déterminer ce que demande l'application à l'aide de la fonction IsMissing. Si Index est absent on renverra l'intégralité du tableau, et un seul élément dans le cas contraire. Bien évidemment si le type sous-jacent de l'argument Index n'est pas un entier ou une valeur convertible en entier cela provoquera une erreur d'incompatibilité de type, ce qui est le comportement normal d'un tableau.

Le même principe est utilisé pour l'écriture mais l'affectation du tableau est rendue plus complexe par la diversité des types de paramètres acceptables. Pour l'anecdote la déclaration de cette procédure est assez inhabituelle puisque elle inclus un argument obligatoire après un argument optionnel.

Property Let List(Optional Index As Variant, NewList As Variant)
Dim Item As Long, i As Long, Temp() As Long
If IsMissing(Index) Then
'Vérification des caractéristiques de NewList (base et dimensions)

If VarType(NewList) = vbArray + vbLong Then        'Selon le type sous-jacent de NewList
For i = 0 To UBound(NewList)
Item = CLng(NewList(i))                         '1 Conversion
If Not CheckItem(Item) Then                     '2 Validation
Err.Raise vbObjectError + 1, , "Valeur incorrecte à l'indice " & i      'Erreur
End If
Next
cpList = NewList                                   '3 Affectation du tableau
Else
ReDim Temp(0 To UBound(NewList))
For i = 0 To UBound(NewList)
Item = CLng(NewList(i))                         '1 Conversion
If CheckItem(Item) Then                         '2 Validation
Temp(i) = Item                               '3 Stockage temporaire
Else
Err.Raise vbObjectError + 1, , "Valeur incorrecte à l'indice " & i      'Erreur
End If
Next
cpList = Temp                                      '4 Affectation du tableau
End If

Else
Item = CLng(NewList)                                  '1 Conversion
If CheckItem(Item) Then                               '2 Validation
cpList(CLng(Index)) = Item                         '3 Affectation de l'élément
Else
Err.Raise vbObjectError + 1, , "Valeur incorrecte à l'indice " & Index        'Erreur
End If
End If
End Property

Telle que ci-dessus, la propriété accepte des tableaux fixes ou dynamiques de tout les types numériques, des Variant contenant des tableaux, des tableaux de Variant. Cette solution est donc très souple du point de vue de l'application puisqu'elle permet par exemple l'utilisation de la fonction Array ou de parcourir la propriété avec For Each.

Sub Test_Propriete_Tableau_2()
Dim MonObjet As NomClasse, TabLong() As Long, i As Long, Item As Variant
Set MonObjet = New NomClasse
MonObjet.List = Array(0, 1, 2, 3)            'Affectation d'un tableau à la propriété
For i = 0 To 3
MonObjet.List(i) = MonObjet.List(i) * 10  'Lecture/écriture d'un élément particulier
Next
For Each Item In MonObjet.List               'Parcours du tableau
Debug.Print Item
Next
MsgBox UBound(MonObjet.List)                 'Affiche l'indice supérieur
TabLong = MonObjet.List                      'Récupère le tableau stocké par la propriété
End Sub

Pour conclure, une fois votre propriété définie et quelque soit l'approche retenue, n'hésitez pas à prévoir des méthodes supplémentaires pour faciliter son usage. Des méthodes de tri ou de recherche sont en général les bienvenues. Enfin si aucune de ces approches ne répond à votre besoin une alternative consiste à implanter votre propriété sous la forme d'une collection.

IV-A-7 - Remarque

Le paragraphe précédent n'étant pas des plus digestes concluons cette section avec un petit hors sujet pour signaler que les procédures Property sont également utilisables dans les modules standard.

Private stdValue As Long

Property Get Value() As Long
Value = stdValue
End Property

Property Let Value(NewValue As Long)
If NewValue < 0 Then
MsgBox "Valeur incorrecte.", vbCritical
Else
stdValue = NewValue
End If
End Property

Cela permet par exemple de mettre en place un contrôle sur une variable globale sans avoir à le répéter à chaque utilisation de cette variable. On peut aussi déclencher une procédure particulière selon la valeur affectée à la variable qui devient alors réactive et ne se contente plus de stocker de l'information.

Il est également possible d'ajouter des propriétés aux objets existants tels que des UserForms afin par exemple de récupérer les choix de l'utilisateur dans la procédure appelante ou au contraire passer des paramètres à ce formulaire avant de l'afficher.

IV-B - Les méthodes

IV-B-1 - Définition

C'est une capacité dont est doté un objet, une action qu'il est capable de réaliser. Un objet Range est par exemple doté d'une méthode Clear qui supprime toutes les mises en formes de la cellule ainsi que son contenu.

IV-B-2 - Créer une méthode

Les méthodes d'un objet sont définies à l'aide de procédures Sub ou Function si la méthode doit renvoyer une valeur. Pour reprendre l'exemple de l'objet Range sa méthode ClearComments se contente d'effectuer des actions sans renvoyer de valeur, si nous devions écrire une telle méthode nous utiliserions une procédure Sub. Par contre la méthode SpecialCells qui permet d'extraire certaines cellules contenues dans un objet Range selon différents critères renvoie un autre objet Range, elle s'écrirait donc avec une procédure Function.

Contrairement aux procédures Property, les procédures Sub et Function acceptent le passage de paramètre par valeur ou par référence. Par défaut le passage par référence est utilisé, il est par nature plus rapide mais implique un risque pour les variables de l'application, et est donc à utiliser en connaissance de cause.

IV-B-3 - Contenu d'une méthode

Une classe est un monde à part, isolé du reste de votre application. En conséquence vous ne devriez jamais faire appel aux objets du monde extérieur, exception faite de ceux dont l'existence ne peut être remise en cause (Application, ThisWorkbook dans un projet Excel, ThisDocument dans un projet Word..) et de ceux que la méthode reçoit en paramètre. Les expressions de type ActiveCell, ActiveSheet ou Selection sont donc particulièrement malvenues dans la quasi-intégralité des cas.

La plupart des méthodes consisteront à manipuler les propriétés du module ou à effectuer des opérations basées sur ces propriétés. Voici l'exemple d'une méthode Reset qui se charge de réinitialiser deux propriétés nommées Value et Name.

Private cpValue As Double
Private cpName As String

Sub Reset()
Value = 0
Name = vbNullString
End Sub

Property Let Value(NewValue As Double)
If NewValue >= 0 Then
cpValue = NewValue                                          'Mise à jour
Else
Err.Raise vbObjectError + 1, , "Valeur négative interdite"  'Erreur
End If
End Property

Property Let Name(NewName As String)
Name = NewName
End Property

Et un exemple de procédure Function.

Public Enum DateStyle
ds_USA
ds_UK
ds_Europe
End Enum

Function DateToString(AnyDate As Date, Style As DateStyle) As String
Select Case Style
Case ds_USA
DateToString = Format(AnyDate, "YY/MM/DD")
Case ds_UK
DateToString = Format(AnyDate, "MM/DD/YY")
Case ds_Europe
DateToString = Format(AnyDate, "DD/MM/YY")
Case Else
Err.Raise 1004, , "Paramètre incorrect."
End Select
End Function

Les procédure Sub et Function d'une classe sont en tout points identiques aux procédures que l'on trouve dans les modules standard, elles peuvent recevoir des arguments obligatoires ou optionnels, être privées ou publiques, ces caractéristiques sont déterminées par leur destination. Quant au choix d'écrire une méthode avec une procédure Sub ou Function, il n'est dicté que par la nécessité de renvoyer une valeur. N'ayant rien de plus à dire sur les méthodes, je profite de ce paragraphe pour faire une petite digression sur les énumérations.

La fonction ci-dessus prend pour paramètre une constante énumérée dont l'avantage principal est de permettre de documenter le code sans ajouter de commentaire, le nom de la constante étant suffisamment explicite. De plus lorsque l'on écrit l'appel de cette fonction l'éditeur propose automatiquement la liste des constantes disponibles, ce qui facilite l'écriture du code.

Les énumérations ne permettent cependant pas de contrôler la valeur effectivement passée à la fonction, elles sont toujours de type Long et si un appel à la fonction est effectuée avec une valeur de ce type n'apparaissant pas dans la liste cela ne produira ni erreur de compilation ni erreur d'exécution. En d'autres termes il s'agit seulement de suggestions pour l'utilisateur de la fonction et il est prudent de prévoir la réception d'un paramètre incorrect, soit en utilisant une des valeurs de la liste par défaut, soit en générant une erreur. Notez enfin que l'instruction Enum n'est disponible qu'à partir d'Office 2000.

IV-C - Les évènements

IV-C-1 - Définition

Un évènement est une réaction d'un objet à une action émanant soit d'un utilisateur soit de l'application qui contient l'objet. Chaque évènement est donc forcément lié à un objet qui subit une action et réagit en envoyant un message. La réception éventuelle de ce message est assurée par une procédure dite évènementielle. La définition d'évènements personnalisés est possible depuis Office 2000.

IV-C-2 - Déclaration

Un évènement se déclare au niveau module en utilisant le mot-clé Event. Voici la déclaration d'un évènement ValueChange doté d'un argument.

Event ValueChange(ByVal PreviousValue As Double)

Par nature les évènements sont toujours publics et ne renvoient jamais de valeurs, ils peuvent ou non recevoir des arguments mais ceux-ci sont alors obligatoires. Encore une fois, et toujours dans un souci d'étanchéité, il est préférable de passer les arguments par valeur plutôt que par référence afin que les variables du module ne puissent pas être modifiées par la procédure évènementielle de l'application.

IV-C-3 - Déclenchement

L'évènement ValueChange devant se déclencher à chaque fois que la propriété Value est modifiée il sera tout naturellement déclenché dans la procédure Property Let Value en utilisant l'instruction RaiseEvent suivie du nom de l'évènement et des arguments nécessaires.

Private cpValue As Double

Property Let Value(NewValue As Double)
Dim Previous As Double
If NewValue >= 0 Then
Previous = cpValue                                          'Sauvegarde
cpValue = NewValue                                          'Mise à jour
RaiseEvent ValueChange(Previous)                            'Déclenchement
Else
Err.Raise vbObjectError + 1, , "Valeur négative interdite"  'Erreur
End If
End Property

Le nombre d'arguments n'est absolument pas lié à la nature de l'évènement, ValueChange pourrait donc très bien recevoir des arguments qui n'ont rien à voir avec la propriété Value ou ne recevoir aucun argument.

IV-C-4 - Utilisation

Comme nous l'avons vu dans la première partie, une variable déclarée avec WithEvents permet d'accéder aux procédures évènementielles d'un objet.

Dim WithEvents MonObjet As NomClasse

Private Sub MonObjet_ValueChange(ByVal PreviousValue As Double)
MsgBox PreviousValue
End Sub

Cette procédure sera déclenchée par toute instruction modifiant la propriété value de notre variable.

Sub TestEvent()
Set MonObjet = New NomClasse
MonObjet.Value = 1              'Appelle Let Value, déclenche MonObjet_ValueChange
End Sub

IV-C-5 - Annulation

Bien qu'un évènement ne retourne pas de valeur il est possible d'obtenir dans la procédure ayant déclenché l'évènement un retour d'information de la part de la procédure évènementielle en passant un argument par référence, ce procédé est utilisé par exemple sous Excel par la procédure Workbook_BeforeClose.

Event BeforeValueChange(ByVal NextValue As Double, ByRef Cancel As Boolean)
Event ValueChange(ByVal PreviousValue As Double)

Property Let Value(NewValue As Double)
Dim CancelEvent As Boolean, Previous As Double
If NewValue >= 0 Then
'L'instance s'apprête à modifier sa propriété Value
RaiseEvent BeforeValueChange(NewValue, CancelEvent)
If CancelEvent Then
'L'application a annulée la modification de la propriété
Else
Previous = cpValue                                          'Sauvegarde
cpValue = NewValue                                          'Mise à jour
RaiseEvent ValueChange(Previous)                            'Déclenchement
End If
Else
Err.Raise vbObjectError + 1, , "Valeur négative interdite"     'Erreur
End If
End Property

La variable CancelEvent est passée à la procédure évènementielle avec la valeur False, si au retour elle est égale à True cela signifie que le changement de valeur ne doit pas se produire.

Cette technique apporte de la souplesse en permettant à l'application de mettre en place des contrôles supplémentaires très facilement. Imaginons que la propriété Value représente une quantité d'articles à commander. La procédure Property Let se charge de vérifier que NewValue est supérieur à zéro puisque par définition on ne commande pas de quantités négatives ou nulles. Par contre le conditionnement d'un article n'est pas une information stable. Le fournisseur de l'article A peut imposer des commandes par multiples de 50 et celui de l'article B par multiples de 100, ces quantités sont de plus susceptibles d'évoluer. Il est donc préférable de laisser le soin à l'application de procéder à ces contrôles via une procédure évènementielle qui se chargera de récupérer le conditionnement d'un article en fonction de l'article et du fournisseur dans une table externe afin de vérifier que NewValue est une valeur correcte ou non, et ainsi décider de mettre à jour Value ou d'informer l'utilisateur.

IV-C-6 - Erreurs et évènements

Si le déclenchement d'un évènement vous semble être une opération anodine pensez que cela revient à donner le contrôle de l'exécution à une procédure évènementielle dont le contenu est par définition inconnu, puisque cette procédure n'est pas encore écrite. Considérons donc le module de classe suivant, composé d'une propriété et d'un évènement.

Private cpValue As Double
Private ValueHistory As Collection
Event ValueChange(ByVal NewValue As Double)

Private Sub Class_Initialize()
Set ValueHistory = New Collection   'Initialisation
End Sub

Property Let Value(NewValue As Double)
On Error Resume Next
ValueHistory.Add cpValue            'Sauvegarde les valeurs successives
RaiseEvent ValueChange(NewValue)    'Déclenche l'évènement
cpValue = NewValue                  'Mise à jour de la propriété
End Property

L'appel à la propriété et la procédure évènementielle suivante:

Dim WithEvents MonObjet As NomClasse

Sub TestEvenement()
Set MonObjet = New NomClasse
MonObjet.Value = 1            'Appelle Let Value, déclenche MonObjet_ValueChange
End Sub

Private Sub MonObjet_ValueChange(ByVal NewValue As Double)
Dim x As Double
x = NewValue / 0
End Sub

Dans cet exemple l'erreur qui survient dans la procédure évènementielle va s'avérer fatale et provoquer l'arrêt du programme en dépit de l'instruction On Error Resume Next précédant le déclenchement de l'évènement. Ce qui ressemble à une exception au mécanisme classique de gestion des erreurs entre procédures appelantes et procédures appelées peut aussi s'interpréter non pas comme un appel de procédure dans le cadre de la pile en cours mais plutôt comme le début d'une nouvelle pile d'appel qui suspend l'exécution de la première.

L'un de mes relecteurs m'a fait remarquer que la procédure évènementielle devrait elle-même contenir un gestionnaire d'erreur ce qui permettrait alors au programme de se poursuivre normalement. C'est la le point essentiel de ce paragraphe, rien ne vous garantit que cette gestion d'erreur est suffisante ni même qu'elle soit présente, en particulier si vos classes sont destinées à être utilisés dans plusieurs projets et/ou par plusieurs personnes.

Le meilleur moyen de se prémunir d'une gestion d'erreur absente ou défaillante au niveau des procédures évènementielles des applications clientes reste donc de respecter la chronologie suivante dans toute procédure source d'évènements:
- Déclenchement des évènements pour lesquels on attend un éventuel retour de l'application (ex : annulation)
- Traitement interne au module
- Déclenchement des évènements pour lesquels on attend aucun retour de l'application

En respectant ce schéma, vous n'avez d'une part pas à vous soucier des éventuelles erreurs qui peuvent se produire à l'extérieur du module et dont vous n'êtes pas responsable, et d'autre part cela vous permet de procéder sereinement à toute opération (ouverture de fichiers, modifications de paramètres) susceptible de modifier l'environnement de l'application. En résumé n'interrompez jamais un traitement par un évènement.

IV-C-7 - Désactiver les évènements d'une classe

Il n'existe aucun mécanisme pour désactiver les évènements d'une classe, le seul moyen de permettre cette fonctionnalité sera donc de prévoir une propriété à cet effet.

Private cpEnableEvents As Boolean
Event ValueChange(ByVal PreviousValue As Double)

Property Get EnableEvents() As Boolean
EnableEvents = cpEnableEvents                      'Renvoi la valeur actuelle
End Property

Property Let EnableEvents(NewEnableEvents As Boolean)
cpEnableEvents = NewEnableEvents                   'Mise à jour de la valeur
End Property

Chaque instruction RaiseEvent du module de classe doit être précédée d'une vérification de la propriété.

Property Let Value(NewValue As Double)
Dim Previous As Double
If NewValue >= 0 Then
Previous = cpValue                                             'Sauvegarde
cpValue = NewValue                                             'Mise à jour
'Vérifie si l'évènement doit être déclenché
If EnableEvents Then RaiseEvent ValueChange(Previous)
Else
Err.Raise vbObjectError + 1, , "Valeur négative interdite"     'Erreur
End If
End Property

L'initialisation de la propriété se fait dans la procédure Class_Initialize.

Private Sub Class_Initialize()
cpEnableEvents = True
End Sub

L'efficacité de cette approche reste toutefois limitée puisqu'elle ne permet pas de désactiver globalement les évènements pour toutes les instances d'une classe.

IV-D - Les collections

IV-D-1 - Introduction

En regardant une bibliothèque on retrouve souvent des classes fonctionnant par couple, les collections et les objets fonctionnels qu'elles regroupent. Pour ne citer qu'un exemple dans la bibliothèque Office la classe CommandBars (les barres d'outils) représente un ensemble d'objets CommandBar (une barre d'outils). Reproduire cette approche permet de proposer au développeur un package plus complet incluant des services supplémentaires comme par exemple des variables partagées entre chaque instance du module fonctionnel ou une détection des évènements au niveau de la collection.

Pour illustrer les avantages de cette approche nous allons donc définir deux nouvelles classes, la première représentera une collection et s'appellera Numbers, la seconde représentera un objet fonctionnel nommé Number servant à stocker un nombre positif et capable de générer des évènements, c'est assez basique mais ce sera suffisant pour notre propos et cela me permet surtout de poursuivre avec les procédures déjà évoquées. Dans une approche simple nous définirions ce module comme ceci.

'Le module Number
Private cpValue As Double
Private cpEnableEvents As Boolean

Event BeforeValueChange(ByVal NextValue As Double, ByRef Cancel As Boolean)
Event ValueChange(ByVal PreviousValue As Double)

Private Sub Class_Initialize()
cpEnableEvents = True
End Sub

Property Get EnableEvents() As Boolean
EnableEvents = cpEnableEvents
End Property

Property Let EnableEvents(NewEnableEvents As Boolean)
cpEnableEvents = NewEnableEvents
End Property

Property Get Value() As Double
Value = cpValue
End Property

Property Let Value(NewValue As Double)
Dim CancelEvent As Boolean, Previous As Double
If NewValue >= 0 Then
If EnableEvents Then
RaiseEvent BeforeValueChange(NewValue, CancelEvent)
End If
If Not CancelEvent Then
Previous = cpValue                                       'Sauvegarde
cpValue = NewValue                                       'Mise à jour
If EnableEvents Then RaiseEvent ValueChange(Previous)
End If
Else
Err.Raise vbObjectError + 1, , "Valeur négative interdite"  'Erreur
End If
End Property

Pour une approche plus élaborée, nous ajouterons à cet objet deux nouvelles propriétés:

- Name: Une chaîne permettant d'identifier chaque instance de manière univoque.

- Parent: Cette propriété renverra un objet Numbers

'Dans le module Number
Private cpName As String
Private cpParent As Numbers

Property Get Name() As String
Name = cpName
End Property

Property Let Name(NewName As String)
cpName = NewName
End Property

Property Get Parent() As Numbers
Set Parent = cpParent
End Property

Property Set Parent(NewParent As Numbers)
If cpParent Is Nothing Then Set cpParent = NewParent
End Property

La propriété Name n'incluse à ce stade aucun contrôle visant à garantir qu'aucune instance ne porte le nom d'une autre, nous y reviendrons. Quant à la propriété Parent, il s'agit d'une propriété en "écriture unique", autrement dit on ne pourra la définir qu'une fois (au moment de la création de l'objet), cela semble logique si l'on admet qu'on n'a qu'une mère et qu'on ne peut en changer.

IV-D-2 - Adapter l'objet Collection

Les collections pouvant stocker simultanément plusieurs types d'objets, notre première tache sera de nous assurer que notre objet Numbers ne puisse contenir que des objets Number. Pour ce faire nous allons emballer une collection dans ce module, c'est-à-dire court-circuiter les méthodes et propriétés d'une collection pour y substituer nos propres méthodes et propriétés.

'Dans le module Numbers
Private cpNumbers As Collection                    'Déclare une collection

Private Sub Class_Initialize()
Set cpNumbers = New Collection                  'Initialise la collection
End Sub

Function Count() As Long                           'Nombre d'éléments
Count = cpNumbers.Count
End Function

Function Item(IndexOrName As Variant) As Number    'Renvoi un élément
Set Item = cpNumbers.Item(IndexOrName)
End Function

Sub Remove(IndexOrName As Variant)                 'Retire un élément
cpNumbers.Remove IndexOrName
End Sub

Count n'appelle guère de commentaires, Item et Remove acceptent un argument Variant afin que l'application puisse utiliser soit le nom de l'objet soit sa position dans la collection.

IV-D-3 - La méthode Add

Notre collection étant privée, la méthode Add sera l'unique point d'entrée permettant à l'application d'y ajouter de nouveaux objets. C'est donc cette méthode, et non pas l'application, qui se chargera de déterminer le nom initial de l'objet en prenant soin de s'assurer qu'il est unique afin que sa propriété Name puisse servir de clé d'identification. Cette méthode doit donc accomplir deux taches distinctes mais intimement liées, d'une part créer et initialiser un nouvel objet, et d'autre part ajouter cet objet à la collection.

'Dans le module Numbers
Function Add() As Number                           'Ajoute un nouvel élément à la collection
Dim TheNumber As Number, TheName As String
Static TheKey As Long
On Error Resume Next
Do                                              'Détermine un nom unique
TheKey = TheKey + 1
TheName = "Number" & CStr(TheKey)
Loop Until cpNumbers.Item(TheName) Is Nothing
On Error GoTo 0
Set TheNumber = New Number                      'Crée un nouvel objet
With TheNumber                                  'Initialise l'objet
Set .Parent = Me
.Name = TheName
End With
cpNumbers.Add TheNumber, TheName                'Ajoute l'objet, utilise le nom comme clé
Set Add = TheNumber                             'Renvoi l'objet à l'application
End Function

L'initialisation du nouvel objet se fait tout naturellement en appelant ses diverses propriétés. Cette construction permet si on le souhaite de transformer Add en constructeur personnalisé, il suffit pour cela de lui ajouter des paramètres (déclarés ici optionnels pour offrir plus de souplesse à l'application) que l'on retransmet ensuite aux propriétés correspondantes de l'objet.

'Dans le module Numbers
Function Add(Optional IValue As Double) As Number
Dim TheNumber As Number, TheName As String
...
On Error GoTo ErrH
Set TheNumber = New Number                'Crée un nouvel objet
With TheNumber                            'Initialise l'objet
Set .Parent = Me
.Name = TheName
.Value = IValue                        'Paramètre fourni par l'application
.EnableEvents = True
End With
cpNumbers.Add TheNumber, TheName          'Ajoute l'objet, utilise le nom comme clé
Set Add = TheNumber                       'Renvoi l'objet à l'application
Exit Function
ErrH:
Err.Raise Err.Number, , Err.Description   'Erreur
End Function

Notez l'apparition d'un gestionnaire d'erreur dans la procédure Add. Si aucune erreur ne se produit pendant l'initialisation de l'objet cela signifie que les paramètres transmis par l'application (ici IValue) sont conformes et Add renvoi le nouvel objet à l'application, dans le cas contraire la méthode Add générera une erreur d'exécution à destination de l'application en fournissant le message d'erreur défini par la propriété source de l'erreur.

Dernière remarque, il est en général pertinent d'empêcher le déclenchement d'évènements tant que l'initialisation de l'objet n'est pas achevée. Dans notre cas ce résultat est obtenu en supprimant la procédure Class_Initialize de l'objet Number afin que la propriété EnableEvents soit la dernière (et non la première) à être initialisée.

A ce stade notre collection présente donc les caractéristiques suivantes:

- Elle se comporte de fait comme si elle était typée, en ne contenant que des objets Number.

- Chaque élément possède une propriété Parent renvoyant l'objet Numbers contenant la collection.

- Chaque élément possède une propriété Name dont la valeur correspond à sa clé.

IV-D-4 - La propriété Name

Cette propriété pose problème car elle doit absolument être synchronisée avec la clé d'identification de l'objet dans la collection faute de quoi celui-ci ne pourrait plus être référencé que par son index. A priori il est donc impossible de la modifier puisqu'il est impossible de modifier (et même de lire) la clé d'un élément d'une collection. La solution consiste à retirer provisoirement l'objet de la collection lorsque on modifie son nom, avant de l'y ajouter à nouveau, une coopération entre l'objet Number et son parent Numbers est donc nécessaire.

'Dans le module Number
Property Let Name(NewName As String)
If cpName = vbNullString Then          'Vrai uniquement à l'initialisation
cpName = NewName
Else
If NewName = vbNullString Then
Err.Raise vbObjectError + 100, , "Impossible d'utiliser une chaine vide." 'Erreur
End If
If Parent.IsFreeName(Me, NewName) = True Then
cpName = NewName                 'Le nom est accepté
Else
Err.Raise vbObjectError + 100, , "Impossible d'utiliser un nom existant." 'Erreur
End If
End If
End Property

Avant de modifier le nom on sollicite une fonction de l'objet parent afin de s'assurer de sa disponibilité.

'Dans le module Numbers
Function IsFreeName(TheNumber As Number, NewName As String) As Boolean
'Vrai si le nom peut être modifié
Dim OneNumber As Number, i As Long
With cpNumbers
On Error Resume Next
Set OneNumber = .Item(NewName)
On Error GoTo 0
If OneNumber Is Nothing Then                    'Le nom est disponible
i = TheNumber.Index                          'Mémorise la position
.Remove i                                    'Retire l'objet de la collection
If i > .Count Then                           'Insere l'objet avec sa nouvelle clé
.Add TheNumber, NewName
Else
.Add TheNumber, NewName, i
End If
IsFreeName = True
Else
If OneNumber Is TheNumber Then IsFreeName = True   'Pas de changement
End If
End With
End Function

Ici la seule vérification porte sur l'unicité du nom mais on pourrait également implanter des règles relatives à sa longueur ou à sa composition (en fait ce serait même indispensable afin de s'assurer que le nom peut être utilisé comme clé). La méthode Index renvoyant la position de l'élément dans la collection est abordée un peu plus loin.

IV-D-5 - Propriétés communes à chaque instance

Via la propriété Parent, chaque objet Number est en mesure d'accéder à un membre public de l'objet Numbers, qui peut donc être vu comme un espace de stockage d'information partagé entre tous les objets Number faisant partie de sa collection. Illustrons cette capacité en revenant sur la propriété EnableEvents, qui permet à l'application de désactiver les évènements de chaque objet Number en effectuant une boucle sur la collection de l'objet Numbers.

Dim MyNumbers As Numbers, i As Long
...
For i = 1 To MyNumbers.Count
MyNumbers.Item(i).EnableEvents = False
Next

Bien évidemment pour rétablir cette propriété il faudra à nouveau parcourir la collection, cela peut être évité en déplaçant cette propriété dans l'objet Numbers.

'Dans le module Numbers
Private cpEnableEvents As Boolean

Property Get EnableEvents() As Boolean
EnableEvents = cpEnableEvents
End Property

Property Let EnableEvents(NewEnableEvents As Boolean)
cpEnableEvents = NewEnableEvents
End Property

'Dans le module Number
Private Property Get EnableEvents() As Boolean
EnableEvents = Parent.EnableEvents
End Property

Dans le module Number, la procédure Property Let EnableEvents et la variable cpEnableEvents doivent être supprimées. Cette propriété n'ayant plus vocation à être accessible à l'application, elle devient privée et plutôt que de renvoyer une variable interne appelle la propriété de l'objet Parent. Notez qu'il serait également possible d'en faire une fonction privée, le résultat serait le même. Enfin il faut modifier la méthode Add de l'objet Numbers pour y désactiver les évènements avant de créer l'objet.

'Dans le module Numbers
Function Add(Optional IValue As Double) As Number
Dim TheNumber As Number, TheName As String, EE As Boolean
...
EE = EnableEvents                         'Sauvegarde la valeur courante
EnableEvents = False                      'Désactive les évènements
On Error GoTo ErrH
Set TheNumber = New Number                'Crée un nouvel objet
With TheNumber                            'Initialise l'objet
Set .Parent = Me
.Name = TheName
.Value = IValue                        'Paramètre fourni par l'application
End With
cpNumbers.Add TheNumber, TheName          'Ajoute l'objet, utilise le nom comme clé
EnableEvents = EE                         'Rétabli la gestion des évènements
Set Add = TheNumber                       'Renvoi l'objet à l'application
Exit Function
ErrH:
EnableEvents = EE                         'Rétabli la gestion des évènements
Err.Raise Err.Number, , Err.Description   'Erreur
End Function

Désormais, à chaque modification de la propriété Value d'un objet Number, les évènements BeforeValueChange et ValueChange ne seront déclenchés qu'après avoir vérifié la propriété EnableEvents de l'objet Numbers. L'application peut donc désactiver les évènements de toutes les instances existantes dans la collection sans recourir à une boucle.

MyNumbers.EnableEvents = False

Nous avons donc gagné en espace mémoire, une seule variable étant utilisée par un nombre quelconque d'objet, mais surtout en simplicité d'utilisation. Enfin, cela nous assure que tous les objets Number se comporteront à l'identique à un instant donné.

IV-D-6 - Gérer les évènements au niveau de la collection

Il est également possible de faire appel à l'objet Parent lorsqu'on déclenche un évènement, ce qui permet à l'application de les gérer au niveau de la collection. Tout d'abord nous allons dupliquer dans le module Numbers les différents évènements de l'objet Number que nous souhaitons faire remonter.

'Dans le module Numbers
Event NumberValueChange(ByVal Nb As Number, ByVal PreviousValue As Double)
Event NumberBeforeValueChange(ByVal Nb As Number, ByVal NextValue As Double, Cancel As Boolean)

Ces évènements de l'objet Numbers sont les même que ceux de l'objet Number, avec un paramètre supplémentaire servant à identifier l'objet ayant déclenché l'évènement. Nous avons également besoin d'une méthode publique qui sera appelée par l'objet Number pour informer Numbers qu'un évènement doit être déclenché. Cette méthode aura besoin de deux paramètres, l'un permettant de déterminer quel évènement doit être déclenché et l'autre d'identifier l'objet Number à la source de l'évènement. Le premier paramètre sera choisi dans une liste de constantes.

Public Enum EventNames
NbValueChange = 1
NbBeforeValueChange = 2
End Enum

En plus de cela, certains évènements devant pouvoir être annulés, un paramètre supplémentaire est nécessaire, notre méthode devrait donc ressembler à ceci.

Sub RaiseAnEvent(EventName As EventNames, Source As Number, Cancel As Boolean)

Cette déclaration est incomplète, en effet les paramètres des évènements (NextValue et PreviousValue) n'y figurent pas, il sera donc impossible de les retransmettre à l'évènement que nous voulons déclencher. Ceci est volontaire car tous nos évènements n'ont pas forcément le même nombre de paramètres. Un moyen simple de gérer ces différents cas consiste à utiliser comme dernier argument un tableau déclaré avec le mot-clé ParamArray.

Sub RaiseAnEvent(EventName As EventNames, Source As Number, Cancel As Boolean, ParamArray Params())

Ce dernier paramètre est un tableau de type Variant dont la dimension n'est pas connue à l'avance, il est donc très souple. Seule restriction, lors de l'appel de cette méthode il sera impossible d'utiliser des arguments nommés, ils seront donc obligatoirement passés par position. Nous pouvons maintenant modifier l'objet Number afin de faire remonter les différents évènements vers la collection.

'Dans le module Number
Property Let Value(NewValue As Double)
Dim CancelEvent As Boolean, Previous As Double
If NewValue >= 0 Then
If EnableEvents Then
'Déclenche l'évènement au niveau de l'instance, puis de la collection
RaiseEvent BeforeValueChange(NewValue, CancelEvent)
Parent.RaiseAnEvent NbBeforeValueChange, Me, CancelEvent, NewValue
End If
If Not CancelEvent Then
Previous = cpValue                                       'Sauvegarde
cpValue = NewValue                                       'Mise à jour
If EnableEvents Then
'Déclenche l'évènement au niveau de l'instance, puis de la collection
RaiseEvent ValueChange(Previous)
Parent.RaiseAnEvent NbValueChange, Me, False, Previous
End If
End If
Else
Err.Raise vbObjectError + 1, , "Valeur négative interdite"  'Erreur
End If
End Property

Reste enfin à compléter la procédure RaiseAnEvent qui ne fera que retransmettre les paramètres qu'elle reçoit en respectant un ordre plus ou moins conventionnel, l'objet source d'abord, les différents paramètres ensuite, et enfin un éventuel paramètre d'annulation.

'Dans le module Numbers
Sub RaiseAnEvent(EventName As EventNames, Source As Number, Cancel As Boolean, ParamArray Params())
Select Case EventName
Case NbValueChange
'Le paramètre Cancel est ignoré pour cet évènement
RaiseEvent NumberValueChange(Source, CDbl(Params(0)))
Case NbBeforeValueChange
RaiseEvent NumberBeforeValueChange(Source, CDbl(Params(0)), Cancel)
'Case un autre évènement
End Select
End Sub

Concernant l'évènement BeforeValuechange la valeur finale de l'argument Cancel sera celle définie dans la procédure évènementielle de niveau collection puisqu'elle s'exécute après celle de niveau élément.

IV-D-7 - Itération avec For Each

La collection étant privée il est normalement impossible de la parcourir en dehors du module Numbers en utilisant l'instruction For Each. Il est toutefois possible de contourner cette limitation à l'aide d'une propriété particulière.

'Dans le module Numbers
Property Get NewEnum() As IUnknown
Set NewEnum = clsNumbers.[_NewEnum]
End Property

_NewEnum est une propriété masquée de l'objet Collection et IUnknown est une classe de la bibliothèque stdole qui fait partie des bibliothèques par défaut de tout projet Office. Le but de cette propriété est de faire passer le module pour une collection au regard du compilateur, qui l'appellera à chaque fois que l'application utilisera For Each avec une variable de type Numbers. Les modifications suivantes ne peuvent être effectuées dans l'éditeur VBE, il faut donc exporter le module et ouvrir le fichier .cls avec un éditeur de texte tel que Notepad pour ajouter des attributs à cette propriété.

'Dans le fichier Numbers.cls
Property Get NewEnum() As IUnknown
Attribute NewEnum.VB_UserMemId = -4
Attribute NewEnum.VB_MemberFlags = "40"
Set NewEnum = cpNumbers.[_NewEnum]
End Property

Illustrons cette capacité en ajoutant une méthode Index à l'objet Number pour renvoyer sa position dans la collection.

'Dans le module Number
Function Index() As Long               'Position de l'élément dans la collection
Dim OneNumber As Number, i As Long
For Each OneNumber In Parent
i = i + 1
If OneNumber Is Me Then Exit For
Next
Index = i
End Function

Le parcours de la collection d'un objet Numbers se fait simplement en désignant cet objet, ici via la propriété Parent. En plus d'un confort pour le développeur cette possibilité apporte également un gain de performance en évitant de passer par la méthode Item de l'objet Numbers. Seul inconvénient la propriété NewEnum doit impérativement être déclarée Public et donc être visible depuis l'application.

Dans la même veine on peut aussi définir une propriété par défaut, même si cela n'apporte pas grand-chose, ici le choix se porte sur Item.

Function Item(IndexOrName As Variant) As Number 'Renvoi un élément
Attribute Item.VB_UserMemId = 0
Set Item = cpNumbers.Item(IndexOrName)
End Function

Les lignes Attribute s'inscrivent immédiatement en dessous du nom de la procédure. Enregistrez le fichier .cls puis réimportez le dans votre projet, les lignes Attribute doivent être invisibles dans l'éditeur VBE. Enfin, vous ne devriez procéder à ces manipulations qu'après avoir finalisé vos modules de classes, les propriétés Attribute ayant parfois tendances à se perdre lorsqu'on modifie les procédures.

J'ai découvert cette astuce bien utile en lisant cette discussion, que je vous recommande d'ailleurs.

IV-D-8 - Objets orphelins

De par leur conception Number et Numbers sont faits pour fonctionner ensemble et non pas l'un sans l'autre, or le fonctionnement de ce couple présente un défaut, la possibilité pour l'application d'obtenir des objets Number orphelins, c'est-à-dire qui ne sont pas référencés dans la collection de leur objet Parent ou qui n'ont pas d'objet Parent. Ceci peut se produire par exemple si l'application crée un objet Number sans passer par la méthode Add de l'objet Numbers.

'Dans un module standard
Dim MyNumber As Number

Set MyNumber = New Number
MyNumber.Value = 3.141592653  'Cause une erreur d'exécution inattendue

Parent n'ayant pas été défini la manipulation de l'objet sera inévitablement source d'erreur nombre de ses méthodes ou propriétés essayant vainement d'y faire appel. L'objet ainsi créé est de fait quasi inutilisable. Autre hypothèse désagréable, l'application retire un objet Number de la collection alors qu'une variable désigne cet objet.

'Dans un module standard
Dim MyNumber As Number, MyNumbers As Numbers

Set MyNumber = MyNumbers.Item(1)
MyNumbers.Remove 1
MsgBox MyNumber.Index   'Renvoi Count mais MyNumber n'appartient plus à la collection

Dans ce cas de figure le comportement va devenir erratique, certaines propriétés ou méthodes échoueront mais d'autres renverront des informations erronées, par exemple un appel à la méthode Index renverra le nombre d'objets dans la collection de MyNumbers ce qui peut laisser croire que MyNumber figure toujours dans la collection, en dernière position. Pour éviter une telle erreur de logique qui serait difficile à cerner, mieux vaut faire en sorte de définir Parent à Nothing lorsque on appelle Remove afin qu'une erreur d'exécution se produise lors de l'appel à Index.

'Dans le module Numbers
Sub Remove(IndexOrName As Variant)
Set Item(IndexOrName).Parent = Nothing    'Brise le lien ascendant
cpNumbers.Remove IndexOrName              'Brise le lien descendant
End Sub

Ceci nous oblige également à modifier la procédure Set Parent de l'objet Number afin que Remove produise l'effet attendu.

'Dans le module Number
Property Set Parent(NewParent As Numbers)
If (cpParent Is Nothing) Or (NewParent Is Nothing) Then Set cpParent = NewParent
End Property

On voit ici que cette propriété n'a vocation à être appelée qu'à deux moments bien précis, soit lors de l'ajout de l'objet à la collection soit lorsque on le retire de cette collection. Autrement dit, et dans l'idéal, elle devrait être en lecture seule pour l'application tout en restant accessible par l'objet Numbers. Bien que ces deux exigences soient contradictoires nous verrons plus bas comment les concilier.

Pour faire un parallèle avec l'objet Worksheet (une feuille de calcul Excel), il ne peut être créé qu'en passant par la méthode Add de la collection Worksheets, et retirer cet objet de la collection implique de le détruire affectant du même coup toutes les variables le désignant. Ceci est impossible à reproduire, et si en théorie la méthode Remove devrait pouvoir servir à détruire l'objet qu'elle retire de la collection, dans la pratique elle ne fait que détruire une référence à l'objet sans affecter les autres références éventuelles.

Etant donné qu'il est impossible d'empêcher l'application de produire des objets orphelins le mieux que l'on puisse faire est donc de préserver la cohérence de la relation entre deux objets Number et Numbers, soit ils se connaissent mutuellement, soit ils s'ignorent complètement, mais en aucun cas l'un ne doit connaître l'autre à son insu.

IV-D-9 - L'évènement Terminate

Je vous disais au début de cet article que l'évènement Terminate d'un objet était déclenché lorsque aucune variable ne désignait plus cet objet, cette condition est en fait nécessaire mais insuffisante. Si un objet A pointe vers un objet B et que cet objet B pointe vers l'objet A, alors et même si aucune variable ne désigne A ou B, l'évènement Terminate de ces deux objets ne se déclenche jamais et ils demeurent en mémoire, tout en restant inaccessibles.

Ce problème peut se produire avec le modèle que je viens de vous décrire. En effet chaque objet Number pointe vers un objet Numbers via sa propriété Parent, pendant que cet objet Numbers pointe vers chaque objet Number via sa collection, il y a donc une référence circulaire entre ces objets. Pour vous en convaincre voici une petite démonstration.

'Dans le module Numbers
Private Sub Class_Terminate()
MsgBox "Numbers.Terminate"
End Sub

'Dans le module Number
Private Sub Class_Terminate()
MsgBox "Number.Terminate " & Me.Name
End Sub

'Dans un module standard
Sub Test_Terminate_1()
Dim i As Long, MyNumbers As Numbers
Set MyNumbers = New Numbers         'Crée un objet Numbers
With MyNumbers
For i = 1 To 10                  'Crée 10 objets Number
.Add
Next
End With
Set MyNumbers = Nothing             'Les objets ne sont pas détruits
End Sub

Lorsque cette procédure s'achève, les objets créés (une instance de Numbers et dix instances de Number) ne sont pas accessibles (aucune variable ne pointe vers l'un de ces objets), et pourtant aucun des évènements Terminate ne s'est produit, conclusion logique la mémoire n'a pas été libérée. Pour éviter ce genre de situation il est donc prudent de briser ces références circulaires en retirant chaque objet Number de la collection de son parent lorsque on veut détruire celui-ci.

'Dans le module Numbers
Sub Clear()                            'Retire tous les éléments de la collection
Dim OneNumber As Number
For Each OneNumber In cpNumbers
Set OneNumber.Parent = Nothing   'Brise les liens ascendants
Next
Set clsNumbers = New Collection     'Brise les liens descendants
End Sub

'Dans un module standard
Sub Test_Terminate_2()
Dim i As Long, MyNumbers As Numbers
Set MyNumbers = New Numbers         'Crée un objet Numbers
With MyNumbers
For i = 1 To 10                  'Crée 10 objets Number
.Add
Next
.Clear                           'Vide la collection
End With
Set MyNumbers = Nothing             'Les objets sont détruits
End Sub

Bien évidemment l'appel à Clear n'a rien d'automatique et ne garantit pas qu'il ne subsistera aucune trace de ces objets. Par exemple si une variable de niveau module pointait vers un élément de la collection au moment de cet appel, cet élément continuera d'exister en tant qu'objet orphelin. Il appartient donc à l'application de gérer rigoureusement ses variables et au concepteur des modules de classes d'utiliser l'évènement Terminate avec précaution.

IV-D-10 - Résumé

Au final, si le montage peut sembler complexe, il reste néanmoins très avantageux du point de vue de l'application puisque en déclarant une seule variable de type Numbers le développeur:

- Dispose d'un nombre quelconque d'instances de l'objet Number, via la collection

- Est assuré que chaque objet porte un nom unique

- Peut librement modifier ce nom

- Peut accéder à un objet Number par son nom ou sa position

- Est en mesure de parcourir la collection en utilisant l'instruction For Each

- Dispose de deux niveaux de procédures évènementielles

- Peut simplement désactiver ces évènements

Ce modèle basique illustrant la relation entre un module fonctionnel (Number) et un module faisant office de collection peut ensuite facilement être enrichi d'autres propriétés, méthodes ou évènements usuels, par exemple:

Pour l'objet Number:
- Tag, une propriété pour stocker des informations variées

Pour l'objet Numbers:
- Sort, une méthode pour trier la collection
- Find, une méthode pour rechercher un (ou plusieurs) éléments
- NewNumber, un évènement signalant l'ajout d'un élément à la collection

IV-E - Les bibliothèques

Une bibliothèque est un ensemble de code dont la seule finalité est de se mettre au service d'une application, autrement dit il s'agit d'un fichier contenant des modules et/ou des fonctions à caractères génériques. Concrètement il s'agira d'un fichier externe à l'application mais de même type que celle-ci (une macro complémentaire .xla pour une application Excel, une base de donnée .mde pour une application Access..).

IV-E-1 - Création

Modifions tout d'abord le nom du projet hébergeant nos modules afin de faciliter son identification: VBE > Outils > Propriétés > Onglet Général, remplacez le nom par défaut (VBAProject) par un nom plus significatif.

Affichons maintenant la fenêtre propriétés (F4) pour revenir, comme promis au tout début de cet article sur la propriété Instancing des modules de classes. Jusqu'à présent nos modules étaient Private, et donc visibles uniquement dans leur projet de résidence, en modifiant cette propriété à PublicNotCreatable nos deux modules deviennent accessibles depuis l'extérieur (Public), cependant la création d'une nouvelle instance n'est possible qu'à l'intérieur du projet (NotCreatable). Pour rendre ces modules disponibles nous n'avons pas d'autre choix que d'écrire dans un module standard une fonction publique renvoyant une nouvelle instance.

Public Function NewNumbers() As Numbers
Set NewNumbers = New Numbers
End Function

Notre bibliothèque ne proposera pas de fonction équivalente pour l'objet Number, ainsi l'application devra d'abord passer par la fonction NewNumbers pour créer un objet Numbers, puis par la méthode Add de cet objet pour créer les objets Number dont elle à besoin, ce qui permet au passage de diminuer le risque de création d'objets orphelins par l'application. Ci-dessus cette fonction ne sert qu'à créer une nouvelle instance, mais on pourrait en profiter pour lui assigner le rôle de constructeur de l'objet Numbers.

Public Function NewNumbers(Name As String) As Numbers
Dim N As Numbers
Set N = New Numbers
N.Name = Name        'En supposant qu'une propriété Name existe pour Numbers
Set NewNumbers = N   'Renvoie le nouvel objet à l'application
End Function

Il ne reste plus qu'à enregistrer le fichier dans le format convenable.

IV-E-2 - Avantages

L'avantage le plus évident à utiliser des bibliothèques est que le code est disponible pour plusieurs applications. D'autre part, le fait de n'avoir qu'un seul fichier simplifie la maintenance, la mise à jour se résumant à remplacer ce fichier.

Autre avantage induit, plus spécifique aux classes, il nous est désormais possible de masquer aux applications clientes les procédures destinées à faire coopérer nos modules entre eux afin de nous assurer qu'elles ne seront pas appelées en dehors de leur contexte normal d'utilisation. Il suffit pour cela de modifier leurs déclarations en utilisant le mot-clé Friend qui restreint la visibilité d'une procédure à son projet de résidence, définissant ainsi une portée intermédiaire entre Public et Private.

'Dans le module Numbers
Friend Function IsFreeName(...) As Boolean

Friend Sub RaiseAnEvent(...)

'Dans le module Number
Friend Property Set Parent(NewParent As Numbers)

En procédant ainsi la propriété Parent devient de fait une propriété en lecture seule au regard de l'application tout en restant disponible pour le module Numbers, ce qui accroît la robustesse de l'ensemble.

IV-E-3 - Evolution

En termes d'évolution il convient de distinguer les éléments privés, librement modifiables, et les éléments publics qui forment l'interface de la bibliothèque. Si rien n'empêche d'ajouter de nouveaux membres publics (méthodes ou propriétés), la modification de la définition d'un membre public (ou sa suppression) représente un risque majeur d'incompatibilité avec le code des applications existantes.

Quelques modifications sont toutefois possibles, par exemple ajouter un argument à une procédure à condition de l'ajouter après les arguments existants et de le définir optionnel. Modifier le type d'un paramètre d'une procédure Sub ou Function est envisageable si vous optez pour un type moins restrictif (de Long à Double par exemple) et à condition qu'il soit passé par valeur, l'inverse est beaucoup plus risqué. Dans l'autre sens la valeur de retour d'une fonction peut passer de Double à Long sans perturber le fonctionnement des applications clientes, l'inverse est susceptible de provoquer un dépassement de capacité.

En ce qui concerne les propriétés, si l'on opte pour un type moins restrictif (Long vers Double) on a un risque d'erreur en lecture, et à l'inverse un type plus restrictif (Double vers Long) engendre un risque d'erreur en écriture. Il est donc fondamental de bien choisir vos types de données au départ.

Le contenu des procédures, même publique, peut par contre évoluer à votre gré, à condition de ne pas les dénaturer. Une procédure de tri définie initialement comme effectuant un tri croissant ne doit pas être modifiée pour effectuer un tri décroissant, mais la manière dont vous effectuez le tri n'a aucune importance du point de vue de l'application.

IV-E-4 - Utilisation

Pour qu'une application puisse utiliser les classes et fonctions isolées dans une bibliothèque il faut lui ajouter une référence vers cette bibliothèque. VBE > Outils > Références cochez la case correspondante au nom de projet choisi plus haut (le fichier en question doit bien sur être ouvert, sinon passez par le bouton Parcourir).

Toute médaille ayant son revers, vos applications ne sont plus auto suffisantes, et si la bibliothèque est déplacée ou renommée vous aurez une référence manquante et des erreurs de compilation 'Type défini par l'utilisateur non défini' les rendront inutilisables.

Enfin, si l'application doit être distribuée à plusieurs utilisateurs vous devrez également distribuer la bibliothèque et prévoir son référencement. Je n'aborde pas ici cette question, les solutions à mettre en œuvre étant largement dépendantes du logiciel (Excel, Access, Word) cible.

IV-F - Programmation orientée objets ?

VBA n'est en aucun cas un langage orienté objet comme peut l'être Java, il permet néanmoins de mettre en œuvre certains principes de la POO soit en intégralité comme l'encapsulation des données avec les procédures Property, soit seulement en partie comme le polymorphisme ou l'héritage.

IV-F-1 - Héritage et polymorphisme

L'héritage est un concept qui veut qu'une classe puisse utiliser des propriétés et méthodes définies par une autre classe. Par exemple les classes Label et CheckBox héritent des méthodes et propriétés définies dans la classe Control. Cela permet de connaître la position d'un Label ou d'un CheckBox sur un formulaire grâce aux propriétés Top et Left bien que la définition de ces propriétés ne fasse pas partie intégrante de ces classes. On pourra qualifier les classes CheckBox et Label de classes dérivées de la classe Control.

Le polymorphisme peut être considéré comme l'autre face de l'héritage, la capacité d'une classe à se comporter comme n'importe laquelle de ses classes dérivées. L'objet Control peut ainsi servir à définir la bordure du Label qu'il représente grâce à la propriété BorderStyle de celui-ci, mais aussi à définir l'état d'un CheckBox grâce à la propriété Value de celui-ci. L'objet Control est donc un objet polymorphe, mais aussi incomplet par nature, puisque il ne peut exister sans un objet (Label, CheckBox etc.) sous-jacent.

Pour illustrer ces concepts nous allons créer trois modules de classes nommés Polygone, Rectangle et Cercle. Vous aurez deviné que Polygone sera notre classe polymorphe, Rectangle et Cercle en seront les classes dérivées. Certains objecteront qu'un cercle n'est pas un polygone, merci d'en faire abstraction pour les besoins de la cause.

IV-F-2 - Implémenter une classe abstraite

Les classes Label et CheckBox s'appuient sur un socle commun défini dans la classe Control, et se différencient l'une de l'autre par un ensemble de propriétés et méthodes qui leurs sont propres. Nous allons donc commencer par définir ce socle commun dans notre classe Polygone.

'Dans la classe Polygone
Public Function Perimeter() As Double
'Renvoie le périmètre d'un Polygone
End Function

Public Function Surface() As Double
'Renvoie la surface d'un Polygone
End Function

Private Sub Class_Initialize()
Err.Raise vbObjectError + 50, , "Impossible de créer une instance de la classe Polygone"
End Sub

Les deux fonctions sont des prototypes, c'est-à-dire des membres d'une classe réduits à une simple déclaration. Etant donné qu'elles ne contiennent aucune instruction elles renverront toujours zéro, et la classe Polygone ne contenant que des prototypes est donc inutilisable en l'état, il s'agit d'une classe abstraite. Comme il n'est pas question d'instancier cette classe il serait utile de la définir comme telle, mais rien ne le permet. On peut donc éventuellement prévoir de générer une erreur dans la procédure Class_Initialize afin d'empêcher l'application de créer un objet qui serait vide de contenu et donc de sens.

Deuxième étape, la définition des éléments particuliers à chacune des classes qui dériveront de la classe abstraite.

'Dans la classe Rectangle
Public Height As Double    'Hauteur du rectangle
Public Width As Double     'Largeur du rectangle

'Dans la classe Cercle
Public Ray As Double       'Rayon du cercle

Etablissons maintenant le lien entre la classe abstraite et ses classes dérivées au moyen de l'instruction Implements. Vous constaterez que la classe Polygone apparaît dans la liste de gauche de chacun de ces modules, et que les prototypes qu'elle contient apparaissent dans la liste de droite. Implements impose aux classes Cercle et Rectangle de redéfinir l'intégralité de l'interface de la classe Polygone, c'est-à-dire de redéclarer les prototypes de la classe Polygone afin de définir pour chacun d'eux un contenu adapté à la nature de l'objet qu'elles définissent. En effet on ne calcule pas la surface d'un cercle de la même manière que celle d'un rectangle.

'Dans la classe Rectangle
Implements Polygone

Private Function Polygone_Perimeter() As Double
Polygone_Perimeter = 2 * (Height + Width)
End Function

Private Function Polygone_Surface() As Double
Polygone_Surface = Height * Width
End Function

'Dans la classe Cercle
Implements Polygone

Private Function Polygone_Perimeter() As Double
Polygone_Perimeter = Ray * 3.141592 * 2
End Function

Private Function Polygone_Surface() As Double
Polygone_Surface = Ray * Ray * 3.141592
End Function

Enfin, les procédures implémentées étant privées il nous reste à écrire dans les classes Cercle et Rectangle une procédure publique permettant de renvoyer le code qu'elles contiennent lorsque le contrôleur de la classe n'est pas un Polygone mais simplement un Rectangle ou un Cercle.

'Dans les classes Cercle et Rectangle
Public Function Perimeter() As Double
Perimeter = Polygone_Perimeter
End Function

Public Function Surface() As Double
Surface = Polygone_Surface
End Function

Ici les fonctions implémentées (Polygone_Surface et Polygone_Perimeter) sont appelées par les fonctions publiques (Surface et Perimeter) mais l'inverse serait bien sur possible. Utilisons maintenant nos différentes classes.

Sub Test()
Dim i As Long
Dim P(1 To 2) As Polygone     'Déclare un tableau contenant 2 polygones
Dim R As Rectangle
Dim C As Cercle

Set P(1) = New Rectangle      'Instancie un rectangle
Set R = P(1)
With R
.Height = 10
.Width = 20
End With

Set P(2) = New Cercle         'Instancie un cercle
Set C = P(2)
C.Ray = 10

For i = 1 To 2
Debug.Print "Surface: " & P(i).Surface & " Perimetre: " & P(i).Perimeter
Next
End Sub

En décortiquant cette procédure somme toute assez simple, il en ressort plusieurs choses. En premier lieu une variable de type Polygone peut s'initialiser avec une instance de n'importe quelle classe qui l'implémente, démontrant ainsi le rôle d'une classe abstraite qui est de servir de réceptacle à des objets partageant des caractéristiques communes et non pas de définir un objet.

Ensuite le processus mis en œuvre par VBA pour afficher les caractéristiques de nos objets consiste à substituer à l'appel d'une fonction de la classe Polygone un appel à la fonction correspondante de la classe ayant servi à initialiser la variable. Une variable de type Polygone est donc susceptible de répondre à une instruction identique de manière différentes selon sa nature (Cercle ou Rectangle) du moment, il est donc possible de la qualifier de polymorphe.

Enfin, et c'est le plus important, la classe abstraite permet d'écrire du code sans connaître à l'avance le type de l'objet sur lequel ce code va s'appliquer à condition de se limiter aux membres implémentés, c'est ici le cas dans la boucle.

A contrario, si vous connaissez la nature de l'objet présent dans la variable il serait logique d'utiliser cette variable pour définir les dimensions (Height, Width ou Ray) de l'objet mais c'est hélas impossible, VBA ne connaissant de Polygone que son interface et pas sa nature il vous renverra immanquablement le message 'Membre de méthode ou de données introuvable'. Une classe implémentée ne permet donc pas d'accéder aux propriétés particulières des classes qui en sont dérivées, et le polymorphisme est donc incomplet.

Il va sans dire que cette limite est particulièrement frustrante et réduit fortement l'intérêt de la classe abstraite lorsque on la compare au type de donnée générique fourni par VBA, Object. Ce type de donnée peut en effet être considéré comme une classe abstraite universelle qui présente l'avantage d'offrir l'accès à tous les membres publics de l'instance de la classe qu'il contient en contrepartie d'une baisse - relative - des performances, d'une moindre lisibilité du code, d'un renoncement à l'autocomplétion du code et d'un moindre contrôle sur la syntaxe par VBA.

IV-F-3 - Implémenter une classe non abstraite

Si l'intérêt de la classe Polygone peut donc sembler limité, il reste possible d'utiliser cette classe pour définir des membres dont ses classes dérivées vont pouvoir hériter.

'Dans la classe Polygone
Private clsName As String

Public Property Get Name() As String
Name = clsName
End Property

Public Property Let Name(NewName As String)
If Len(NewName) > 2 Then
clsName = NewName
Else
Err.Raise vbObjectError + 60, , "Le nom est trop court."
End If
End Property

Nous définissons ici une propriété Name incluant un contrôle sur la longueur du nom, le but de la manœuvre étant de faire profiter Cercle et Rectangle de cette vérification sans la reproduire dans chaque module. Pour cela nous déclarons dans chacune de ces classes une variable privée de type Polygone.

'Dans les classes Cercle et Rectangle
Private clsPolygone As Polygone

Private Sub Class_Initialize()
Set clsPolygone = New Polygone
End Sub

Evidemment, cette approche est incompatible avec le déclenchement d'une erreur dans la procédure Class_Initialize de la classe Polygone, qui n'est plus tout à fait abstraite. Les procédures implémentant la propriété Name se contenteront d'appeler la propriété Name de cette Variable.

'Dans les classes Cercle et Rectangle
Private Property Let Polygone_Name(RHS As String)
clsPolygone.Name = RHS
End Property

Private Property Get Polygone_Name() As String
Polygone_Name = clsPolygone.Name
End Property

Enfin, il ne reste qu'à définir dans les classes dérivées une propriété publique, faute de quoi la propriété Name d'un Cercle ou d'un Rectangle ne serait accessible qu'en passant par une variable de type Polygone.

'Dans les classes Cercle et Rectangle
Public Property Get Name() As String
Name = clsPolygone.Name
End Property

Public Property Let Name(NewName As String)
clsPolygone.Name = NewName
End Property

Si l'instruction Implements permet de profiter d'un polymorphisme limité elle n'est en revanche d'aucun secours en matière d'héritage et VBA ne propose en fait aucun mécanisme permettant d'atteindre ce but. La solution se résume donc finalement à emballer la classe dont on veut hériter, indépendamment du fait qu'elle soit ou non implémentée par la classe héritière.

Cette simulation d'héritage fonctionne parfaitement et ne présente pas d'autre défaut que sa lourdeur si ce n'est qu'il vous appartient de veiller à ce que le processus soit mis en œuvre de la même façon dans toutes les classes dérivées. Il va de soi que l'intérêt de ce type d'opération - la factorisation du code - augmente avec le nombre de classes dérivées, de membres hérités et avec la complexité de ces membres.

Pour conclure, le polymorphisme n'étant que partiellement pris en charge et l'héritage ne pouvant qu'être simulé, la mise en œuvre de ces concepts reste assez anecdotique.

V - Conclusion

Bien entendu les exemples proposés dans cet article ne le sont qu'à titre d'illustration, l'essentiel étant d'en comprendre l'esprit. J'espère que cela vous aura permis de mieux appréhender le fonctionnement et les possibilités offertes par les modules de classes, et éventuellement donné envie de les utiliser dans vos futurs développements.

Je tiens à remercier Pierre Fauconnier, Maxence Hubiche, Starec, dolphy35, jeannot45 et particulièrement PapyTurbo, qui ont pris la peine de relire cet article, de valider les informations présentées, de tester les exemples et de me faire des suggestions ainsi que Silkyroad pour m'avoir encouragé à franchir le pas et aidé à publier ce premier article.

Enfin quelques liens sur ce thème:

- L'article de Microsoft (en anglais): http://msdn.microsoft.com/en-us/library/aa140954(office.10).aspx

- L'article de sinarf plus orienté vers Access: https://sinarf.developpez.com/access/vbaclass/

- Celui de Pierre Fauconnier traitant également de VB6: https://fauconnier.developpez.com/articles/vba/general/classes/

Et quelques exemples trouvés sur DVP :

- ClasseFileSearch , pour remplacer l'objet FileSearch dans les versions 2007 d'Office, par Silkyroad .

- DateBox, pour saisir une date dans un formulaire, par moi-même.

- clGdiPlus, classe de gestion d'image, par Arkham46 .