Introduction à la programmation orientée objet (POO) en C#

Baptiste Pesquet

Ce livre est une introduction à la programmation orientée objet (POO). Il présente de manière progressive et illustrée les concepts essentiels et les savoir-faire associés :

- écriture et utilisation de classes ;

- ajout de constructeurs et d'accesseurs, encapsulation ;

- gestion des objets en mémoire (notion de référence d'objet) ;

- mise en relation de classes : association et héritage ;

- redéfinition et surcharge de méthodes, membres statiques ;

- gestion des exceptions.

Les exemples de code sont écrits en C#, mais sont facilement transposables à d'autres langages orientés objet comme Java.

Commentez

- Titre : Introduction à la programmation orientée objet (POO) en C#

- Auteur : Baptiste Pesquet

- Parution : 26 juin 2017

- Licence : Ce livre est publié sous la licence Creative Commons BY-NC-SA. Son code source est disponible sur GitHub. N'hésitez pas à contribuer à son amélioration en utilisant les issues pour signaler des erreurs et les pull requests pour proposer des ajouts.

Sommaire

- I. Introduction

- I-A. Prérequis

- II. Initiation à la programmation orientée objet

- II-A. Introduction

- II-A-1. Pourquoi utiliser la programmation orientée objet ?

- II-A-2. Histoire de la programmation orientée objet

- II-B. Première approche de la POO

- II-B-1. La notion d'objet

- II-B-2. Objet = état + actions

- II-B-3. La notion de classe

- II-B-4. Représentation graphique

- II-B-5. En résumé

- II-C. Programmer avec des objets

- II-C-1. Écriture d'une classe

- II-C-2. Utilisation d'une classe

- II-C-3. Déclaration et instanciation

- II-C-4. Ajout d'une méthode

- II-C-5. Instanciation de plusieurs objets

- III. Principaux concepts objets

- III-A. Constructeur

- III-A-1. Rôle

- III-A-2. Constructeur par défaut

- III-B. Encapsulation

- III-B-1. Exemple d'utilisation

- III-B-2. Définition

- III-B-3. Avantages

- III-C. Accesseurs

- III-C-1. Définition

- III-C-2. Spécificités du langage C#

- III-C-3. Avantages

- III-C-4. Attributs en lecture seule

- IV. Gestion des objets en mémoire

- IV-A. Préambule

- IV-B. Création d'un nouvel objet

- IV-C. Affectation d'objets

- IV-C-1. La spécificité des objets

- IV-C-2. La notion de référence

- IV-C-3. Types valeurs et types références

- IV-D. Comparaison d'objets

- IV-E. Passage d'un objet en paramètre

- IV-F. Destruction d'un objet

- V. La relation d'association

- V-A. Introduction aux relations entre objets

- V-A-1. La nécessité de relations

- V-A-2. Contexte d'exemple

- V-A-3. Evolution des besoins

- V-A-4. Modélisation du client

- V-B. Associations entre classes

- V-B-1. Définition

- V-B-2. Multiplicités d'une association

- V-B-3. Navigabilité d'une association

- V-C. Types particuliers d'associations

- V-C-1. L'agrégation

- V-C-2. La composition

- V-D. Traduction en code d'une association

- VI. La relation d'héritage

- VI-A. Premiers pas

- VI-A-1. Exemple d'utilisation

- VI-A-2. Représentation graphique

- VI-A-3. Définition

- VI-A-4. Avantages

- VI-A-5. Héritage et encapsulation

- VI-B. Polymorphisme

- VI-C. Classes et méthodes abstraites

- VI-C-1. Évolution des besoins

- VI-C-2. Création d'une classe abstraite

- VI-C-3. Modélisation des caractéristiques

- VI-C-4. Modélisation du comportement

- VI-C-5. Déclaration d'une méthode abstraite

- VI-C-6. Redéfinition d'une méthode abstraite

- VI-C-7. Bilan

- VI-D. Méthodes virtuelles

- VI-D-1. Évolution du contexte

- VI-D-2. Mise en œuvre

- VI-D-3. Bilan

- VII. Compléments sur l'écriture de classes

- VII-A. Contexte d'exemple

- VII-B. Autoréférence : le mot-clé this

- VII-C. Utilisation des méthodes de base

- VII-C-1. Découverte de la classe Object

- VII-C-2. Redéfinition de ToString

- VII-D. Surcharge de méthodes

- VII-D-1. Définition de plusieurs constructeurs

- VII-D-2. Chaînage de constructeurs

- VII-D-3. Surcharge d'une méthode

- VII-D-4. Surcharge et valeur de retour

- VII-E. Membres de classe

- VII-E-1. Evolution des besoins

- VII-E-2. Définition d'un attribut de classe

- VII-E-3. Définition d'une méthode de classe

- VIII. Gestion des exceptions

- VIII-A. Introduction : qu'est-ce qu'une exception ?

- VIII-B. Fonctionnement général des exceptions

- VIII-B-1. Contexte choisi

- VIII-B-2. Un premier exemple

- VIII-B-3. Analyse de l'exemple

- VIII-B-4. Conclusion provisoire

- VIII-C. Détail du fonctionnement des exceptions

- VIII-C-1. Une exception est un objet

- VIII-C-2. Une exception remonte la chaîne des appels

- VIII-C-3. Les exceptions forment une hiérarchie

- VIII-D. Avantages apportés par les exceptions

- VIII-E. Bonnes pratiques dans la gestion des exceptions

- VIII-E-1. Savoir quand lever une exception

- VIII-E-2. Savoir quand intercepter une exception

- VIII-E-3. Savoir quand créer ses propres classes d'exceptions

- IX. Note de la rédaction de Developpez.com

I - Introduction

Ce livre est un support de cours à l'Ecole Nationale Supérieure de Cognitique.

I-A - Prérequis

La lecture de ce livre nécessite une connaissance des bases de la programmation :

- notions de variable et de type ;

- structures conditionnelles et itératives (boucles) ;

- programmation modulaire (sous-programmes) et passage de paramètres ;

- tableaux et listes.

II - Initiation à la programmation orientée objet

L'objectif de ce chapitre est de découvrir ce qu'est la programmation orientée objet.

Les exemples de code associés sont disponibles en ligne.

II-A - Introduction

II-A-1 - Pourquoi utiliser la programmation orientée objet ?

La programmation de logiciels a connu il y a quelques années le passage d'une ère artisanale à une ère industrielle. Des logiciels de plus en plus complexes doivent être réalisés dans des délais de plus en plus courts, tout en maintenant le meilleur niveau de qualité possible. Comment faire pour répondre à ces exigences contradictoires ? La solution passe par l'emploi de techniques de développement adaptées, comme l'illustre l'extrait ci-dessous.

"(…) Il est clair que le monde du logiciel ne progresse pas aussi vite que celui du matériel. Qu'ont donc les développeurs de matériel que les développeurs de logiciels n'ont pas ? La réponse est donnée par les composants. Si les ingénieurs en matériel électronique devaient partir d'un tas de sable à chaque fois qu'ils conçoivent un nouveau dispositif, si leur première étape devait toujours consister à extraire le silicium pour fabriquer des circuits intégrés, ils ne progresseraient pas bien vite. Or, un concepteur de matériel construit toujours un système à partir de composants préparés, chacun chargé d'une fonction particulière et fournissant un ensemble de services à travers des interfaces définies. La tâche des concepteurs de matériel est considérablement simplifiée par le travail de leurs prédécesseurs.
La réutilisation est aussi une voie vers la création de meilleurs logiciels. Aujourd'hui encore, les développeurs de logiciels en sont toujours à partir d'une certaine forme de sable et à suivre les mêmes étapes que les centaines de programmeurs qui les ont précédés. Le résultat est souvent excellent, mais il pourrait être amélioré. La création de nouvelles applications à partir de composants existants, déjà testés, a toutes chances de produire un code plus fiable. De plus, elle peut se révéler nettement plus rapide et plus économique, ce qui n'est pas moins important. (…)"

David Chappel, « Au cœur de ActiveX et OLE », 1997

La programmation orientée objet, souvent abrégée POO, permet de concevoir une application sous la forme d'un ensemble de briques logicielles appelées des objets. Chaque objet joue un rôle précis et peut communiquer avec les autres objets. Les interactions entre les différents objets vont permettre à l'application de réaliser les fonctionnalités attendues.

La POO facilite la conception de programmes par réutilisation de composants existants, avec tous les avantages évoqués plus haut. Elle constitue le standard actuel (on parle de paradigme) en matière de développement de logiciels.

II-A-2 - Histoire de la programmation orientée objet

Les concepts de la POO naissent au cours des années 1970 dans des laboratoires de recherche en informatique. Les premiers langages de programmation véritablement orientés objet ont été Simula, puis Smalltalk.

À partir des années 1980, les principes de la POO sont appliqués dans de nombreux langages comme Eiffel (créé par le Français Bertrand Meyer), C++ (une extension objet du langage C créé par le Danois Bjarne Stroustrup) ou encore Objective C (une autre extension objet du C utilisé, entre autres, par l'iOS d'Apple).

Les années 1990 ont vu l'avènement des langages orientés objet dans de nombreux secteurs du développement logiciel, et la création du langage Java par la société Sun Microsystems. Le succès de ce langage, plus simple à utiliser que ses prédécesseurs, a conduit Microsoft à riposter en créant au début des années 2000 la plate-forme .NET et le langage C#, cousin de Java.

De nos jours, de très nombreux langages permettent d'utiliser les principes de la POO dans des domaines variés : Java et C# bien sûr, mais aussi PHP (à partir de la version 5), VB.NET, PowerShell, Python, etc. Une connaissance minimale des principes de la POO est donc indispensable à tout informaticien, qu'il soit développeur ou non.

REMARQUE : la POO s'accompagne d'un changement dans la manière de penser les problèmes et de concevoir l'architecture des applications informatiques. C'est ce que l'on appelle l'analyse (ou la modélisation) orientée objet. Son principal support est le langage de modélisation UML.

II-B - Première approche de la POO

II-B-1 - La notion d'objet

Quand on utilise la POO, on cherche à représenter le domaine étudié sous la forme d'objets. C'est la phase de modélisation orientée objet.

Un objet est une entité qui représente (modélise) un élément du domaine étudié : une voiture, un compte bancaire, un nombre complexe, une facture, etc.

II-B-2 - Objet = état + actions

Cette équation signifie qu'un objet rassemble à la fois :

- des informations (ou données) qui le caractérisent ;

- des actions (ou traitements) que l'on peut exercer sur lui ;

Imaginons que l'on souhaite modéliser des comptes bancaires pour un logiciel de gestion. On commence par réfléchir à ce qui caractérise un compte bancaire, puis on classe ces éléments en deux catégories :

- les informations liées à un compte bancaire ;

- les actions réalisables sur un compte bancaire.

En première approche, on peut considérer qu'un compte bancaire est caractérisé par un titulaire, un solde (le montant disponible sur le compte) et utilise une certaine devise (euro, dollar, etc.). Les actions réalisables sur un compte sont le dépôt d'argent (crédit) et le retrait (débit).

On peut regrouper les caractéristiques de notre objet « compte bancaire » dans ce tableau.

Informations

Actions

titulaire, solde, devise

créditer, débiter

DÉFINITION : un objet se compose d'informations et d'actions. Les actions utilisent (et parfois modifient) les informations de l'objet.

- L'ensemble des informations d'un objet donné est appelé son état.

- L'ensemble des actions applicables à un objet représente son comportement.

REMARQUE : les actions associées à un objet s'expriment généralement sous la forme de verbes à l'infinitif (créditer, débiter).

À un instant donné, l'état d'un objet « compte bancaire » sera constitué par les valeurs de son titulaire, son solde et sa devise. L'état de l'objet « compte bancaire de Paul » sera, sauf exception, différent de l'état de l'objet « compte bancaire de Pierre ».

II-B-3 - La notion de classe

Nous venons de voir que l'on pouvait représenter un compte bancaire sous la forme d'un objet. Imaginons que nous voulions gérer les différents comptes d'une banque. Chaque compte aura son propre titulaire, son solde particulier et sa devise. Mais tous les comptes auront un titulaire, un solde et une devise, et permettront d'effectuer les mêmes opérations de débit/crédit. Chaque objet « compte bancaire » sera construit sur le même modèle : ce modèle est appelé une classe.

DÉFINITION : une classe est un modèle d'objet. C'est un nouveau type créé par le programmeur et qui sert de modèle pour tous les objets de cette classe. Une classe spécifie les informations et les actions qu'auront en commun tous les objets qui en sont issus.

Le compte en banque appartenant à Jean, dont le solde est de 450 euros, est un compte bancaire particulier. Il s'agit d'un objet de la classe « compte bancaire ». En utilisant le vocabulaire de la POO, on dit que l'objet « compte bancaire de Jean » est une instance de la classe « compte bancaire ».

ATTENTION : ne pas confondre objet et classe. Une classe est un type abstrait (exemple : un compte bancaire en général), un objet est un exemplaire concret d'une classe (exemple : le compte bancaire de Jean).

Un objet est une variable particulière dont le type est une classe.

II-B-4 - Représentation graphique

Afin de faciliter la communication entre les programmeurs n'utilisant pas le même langage de programmation objet, il existe un standard de représentation graphique d'une classe. Ce standard fait partie de la norme UML (Unified Modeling Language). On parle de diagramme de classe.

Voici la représentation de notre classe « compte bancaire » réalisée par Visual Studio.

Voici la même classe représentée sous la forme d'un diagramme conforme au standard UML.

Quel que soit l'outil utilisé, on observe que la classe est décomposée en deux parties :

- les champs, qui correspondent aux informations de la classe : devise, solde et titulaire ;

- les méthodes, qui correspondent aux actions réalisables : créditer, débiter et décrire.

DÉFINITION : une méthode représente une action réalisable sur un objet de la classe dans laquelle cette méthode est définie. Une méthode représente une partie du comportement d'une classe.

II-B-5 - En résumé

- La POO consiste à programmer en utilisant des objets.

- Un objet modélise un élément du domaine étudié (exemples : un compte bancaire, une voiture, un satellite, etc.).

- Un objet est une instance de classe. Une classe est un type abstrait, un objet est un exemplaire concret de cette classe.

- Une classe regroupe des informations et des actions.

- Les informations sont stockées sous la forme de champs. Les champs décrivent l'état d'un objet.

- Les actions réalisables sur un objet sont représentées par des méthodes. Elles expriment ce que les objets peuvent faire, leur comportement.

Les informations stockées par un objet peuvent être appelées champs, attributs ou encore propriétés. Attention, ce dernier terme est ambigu en C# (voir plus loin).

II-C - Programmer avec des objets

II-C-1 - Écriture d'une classe

Une fois la modélisation d'une classe terminée, il est possible de la traduire dans n'importe quel langage de programmation orienté objet.

Voici la traduction en C# de la classe CompteBancaire.

1. public class CompteBancaire

2. {

3. public string titulaire;

4. public double solde;

5. public string devise;

7. public void Crediter(double montant)

8. {

9. solde = solde + montant;

10. }

11. public void Debiter(double montant)

12. {

13. solde = solde - montant;

14. }

15. }

La définition d'une classe commence par le mot-clé class. On retrouve ensuite la définition des champs (attributs) et des méthodes de la classe. On remarque que les méthodes utilisent (et modifient) les valeurs des attributs.

Une méthode est une sorte de miniprogramme. On peut y déclarer des variables locales et y utiliser tous les éléments de programmation déjà connus (alternatives, boucles, etc.).

On remarque un nouveau mot-clé : public, sur lequel nous reviendrons ultérieurement.

II-C-2 - Utilisation d'une classe

En utilisant le modèle fourni par la classe, il est possible de créer autant d'objets que nécessaire. Différents objets d'une même classe disposent des mêmes attributs et des mêmes méthodes, mais les valeurs des attributs sont différentes pour chaque objet. Par exemple, tous les comptes bancaires auront un solde, mais sauf exception, ce solde sera différent pour chaque compte.

Le programme C# ci-dessous utilise la classe CompteBancaire définie plus haut pour créer le compte de Jean et y effectuer deux opérations.

1. static void Main(string[] args)

2. {

3. CompteBancaire comptePierre;          // déclaration d'un nouvel objet

4. comptePierre = new CompteBancaire();  // instanciation de cet objet

6. // affectations de valeurs aux attributs

7. comptePierre.titulaire = "Pierre";

8. comptePierre.solde = 0;

9. comptePierre.devise = "euros";

11. // appels des méthodes

12. comptePierre.Crediter(300);

13. comptePierre.Debiter(500);

15. string description = "Le solde du compte de " + comptePierre.titulaire +

16. " est de " + comptePierre.solde + " " + comptePierre.devise;

17. Console.WriteLine(description);

18. }

À la fin du programme, l'attribut solde de l'objet comptePierre contient la valeur -200.

II-C-3 - Déclaration et instanciation

On remarque que la création de l'objet comptePierre se fait en deux étapes :

1. Déclaration de l'objet ;

2. Instanciation de l'objet :

1. CompteBancaire comptePierre;          // déclaration d'un nouvel objet

2. comptePierre = new CompteBancaire();  // instanciation de cet objet

3. // ...

La déclaration permet de créer une nouvelle variable, appelée comptePierre dans l'exemple ci-dessus. À ce stade, aucune réservation de mémoire n'a eu lieu pour cet objet. Il est donc inutilisable.

Le type de la variable comptePierre est CompteBancaire. Le type d'un objet est sa classe.

L'instanciation utilise l'opérateur new, qui permet de réserver une zone mémoire spécifique pour l'objet. On dit que l'objet est instancié par l'opérateur new. Sans cette étape indispensable, l'objet déclaré ne peut pas être utilisé.

DÉFINITION : l'instanciation est l'opération qui consiste à créer un nouvel objet à partir d'une classe, au moyen de l'opérateur new.

Il est courant de rassembler sur une même ligne déclaration et instanciation d'un objet.

CompteBancaire comptePierre = new CompteBancaire();  // déclaration et instanciation d'un nouvel objet
// ...

ATTENTION : ne pas confondre instanciation et initialisation :

- instancier, c'est créer un nouvel objet (opérateur new) ;

- initialiser, c'est donner une valeur initiale à quelque chose (opérateur =).

II-C-4 - Ajout d'une méthode

Comme il est fastidieux de construire la description d'un compte bancaire à chaque fois que l'on en a besoin, on voudrait ajouter une opération de description du compte. Cette action est réalisée par une nouvelle méthode nommée Decrire ajoutée à la classe CompteBancaire. La description est renvoyée sous la forme d'une chaîne de caractères (string).

1. public class CompteBancaire

2. {

3. // ...

5. // Renvoie la description d'un compte

6. public string Decrire()

7. {

8. string description = "Le solde du compte de " +  titulaire + " est de " + solde + " " + devise;

9. return description;

10. }

11. }

II-C-5 - Instanciation de plusieurs objets

Voici un autre programme qui gère les comptes de Pierre et de Paul. Il permet à l'utilisateur de saisir un montant qui sera débité à Pierre et crédité à Paul.

1. static void Main(string[] args)

2. {

3. CompteBancaire comptePierre = new CompteBancaire();

4. comptePierre.titulaire = "Pierre";

5. comptePierre.solde = 500;

6. comptePierre.devise = "euros";

8. CompteBancaire comptePaul = new CompteBancaire();

9. comptePaul.titulaire = "Paul";

10. comptePaul.solde = 150;

11. comptePaul.devise = "euros";

13. Console.Write("Entrez le montant du transfert : ");

14. double montantTransfert = Convert.ToDouble(Console.ReadLine());

15. comptePierre.Debiter(montantTransfert);

16. comptePaul.Crediter(montantTransfert);

18. Console.WriteLine(comptePierre.Decrire());

19. Console.WriteLine(comptePaul.Decrire());

20. }

III - Principaux concepts objets

L'objectif de ce chapitre est de présenter les concepts essentiels de la programmation orientée objet.

Les exemples de code associés sont disponibles en ligne.

III-A - Constructeur

Reprenons l'exemple de la classe CompteBancaire du chapitre précédent.

Tout compte a nécessairement un titulaire, un solde initial et une devise lors de sa création. On aimerait pouvoir instancier un objet de la classe CompteBancaire en définissant directement les valeurs de ses attributs. Pour cela, nous allons ajouter à notre classe une méthode particulière : le constructeur.

1. public class CompteBancaire

2. {

3. public string titulaire;

4. public double solde;

5. public string devise;

7. // Constructeur

8. public CompteBancaire(string leTitulaire, double soldeInitial, string laDevise)

9. {

10. titulaire = leTitulaire;

11. solde = soldeInitial;

12. devise = laDevise;

13. }

15. // ...

III-A-1 - Rôle

DÉFINITION : le constructeur est une méthode spécifique dont le rôle est de construire un objet, le plus souvent en initialisant ses attributs.

ATTENTION : le nom du constructeur doit être identique au nom de la classe et sa définition ne comporte pas le mot-clé void.

L'utilisation d'un constructeur se fait au moment de l'instanciation de l'objet (opérateur new), en passant en paramètres les futures valeurs des attributs de l'objet créé.

1. // déclaration et instanciation d'un nouvel objet en utilisant son constructeur

2. CompteBancaire comptePierre = new CompteBancaire("Pierre", 0, "euros");

4. // appels de méthodes

5. comptePierre.Crediter(300);

6. comptePierre.Debiter(500);

7. Console.WriteLine(comptePierre.Decrire());

III-A-2 - Constructeur par défaut

Lorsqu'une classe ne définit aucun constructeur (comme dans l'exemple du chapitre précédent), un constructeur par défaut sans aucun paramètre est implicitement créé. Il n'a aucun comportement mais son existence permet d'instancier des objets de cette classe.

En revanche, toute définition explicite d'un constructeur dans une classe « désactive » le constructeur par défaut. Dans notre exemple actuel, on ne peut plus instancier un compte bancaire sans lui fournir les trois paramètres que son constructeur attend.

REMARQUE : une classe peut disposer de plusieurs constructeurs initialisant différents attributs. Nous étudierons cette possibilité dans un prochain chapitre.

III-B - Encapsulation

L'écriture de classes offre d'autres avantages que le simple regroupement de données et de traitements. Parmi ceux-ci figure la possibilité de restreindre l'accès à certains éléments de la classe. C'est ce que l'on appelle l'encapsulation.

III-B-1 - Exemple d'utilisation

On souhaite qu'un compte bancaire créé ne puisse pas changer de titulaire ni de devise. Cela est possible en définissant les attributs titulaire et devise comme étant privés.

1. public class CompteBancaire

2. {

3. private string titulaire;   // attribut privé

4. public double solde;

5. private string devise;      // attribut privé

7. public CompteBancaire(string leTitulaire, double soldeInitial, string laDevise)

8. {

9. titulaire = leTitulaire;

10. solde = soldeInitial;

11. devise = laDevise;

12. }

14. // ...

À présent, la seule manière de définir des valeurs pour titulaire et devise est d'utiliser le constructeur. Toute tentative d'accès externe aux propriétés privées générera une erreur lors de la compilation.

1. CompteBancaire comptePierre = new CompteBancaire("Pierre", 0, "euros");

3. comptePierre.titulaire = "Pierre";  // Erreur : titulaire est un attribut privé

4. comptePierre.solde = 500;           // OK : solde est un attribut public

5. comptePierre.devise = "euros";      // Erreur : devise est un attribut privé

III-B-2 - Définition

Les mots-clés public et private permettent de modifier le niveau d'encapsulation (on parle aussi de visibilité ou d'accessibilité) des éléments de la classe (attributs et méthodes) :

- un élément public est librement utilisable depuis le reste du programme ;

- un élément privé est uniquement utilisable depuis les méthodes de la classe elle-même.

REMARQUE : dans de nombreux langages dont le C#, il existe un niveau d'encapsulation intermédiaire (protected) qui sera étudié plus loin.

DÉFINITION : l'encapsulation est l'un des principes fondamentaux de la POO. Il consiste à restreindre l'accès à certains éléments d'une classe (le plus souvent ses attributs). L'objectif de l'encapsulation est de ne laisser accessible que le strict nécessaire pour que la classe soit utilisable.

CONSEIL : sauf cas particulier, on donne le niveau de visibilité private à tous les attributs d'une classe afin d'assurer leur encapsulation par défaut.

1. public class CompteBancaire

2. {

3. private string titulaire;

4. private double solde;

5. private string devise;

7. // ...

III-B-3 - Avantages

L'encapsulation offre de nombreux avantages :

- diminution des risques de mauvaise manipulation d'une classe ;

- création de classes « boîtes noires » par masquage des détails internes ;

- possibilité de modifier les détails internes d'une classe (la manière dont elle fonctionne) sans changer son comportement extérieur (ce qu'elle permet de faire).

III-C - Accesseurs

L'encapsulation des attributs a permis d'interdire toute modification (accidentelle ou volontaire) des données d'un compte bancaire. Cependant, il est maintenant impossible de consulter le solde, le titulaire ou la devise d'un compte créé, ce qui est gênant. On aimerait pouvoir accéder aux données de la classe, tout en maintenant un certain niveau de contrôle. Cela est possible en ajoutant des accesseurs à la classe.

III-C-1 - Définition

DÉFINITION : un accesseur est une méthode le plus souvent publique qui permet d'accéder à un attribut privé.

- Un accesseur en lecture (getter) permet de lire la valeur d'un attribut.

- Un accesseur en écriture (mutateur ou setter) permet de modifier la valeur d'un attribut.

III-C-2 - Spécificités du langage C#

En C#, les accesseurs prennent la forme de propriétés. Une propriété se manipule comme un champ, mais il s'agit en réalité d'un couple d'accesseurs get et set. Dans la plupart des autres langages, les accesseurs sont des méthodes de la forme getXXX et setXXX.

Voici la classe CompteBancaire modifiée pour intégrer des accesseurs vers ses attributs, ainsi que son nouveau diagramme de classe.

1. public class CompteBancaire

2. {

3. private string titulaire;

4. private double solde;

5. private string devise;

7. public string Titulaire

8. {

9. get { return titulaire; }

10. set { titulaire = value; }

11. }

13. public double Solde

14. {

15. get { return solde; }

16. set { solde = value; }

17. }

19. public string Devise

20. {

21. get { return devise; }

22. set { devise = value; }

23. }

25. // ...

Depuis la version 3 du langage, on peut définir les propriétés de manière automatique. Dans ce cas, l'attribut disparaît de la définition de le classe : sa présence est implicite. Voici l'exemple précédent réécrit en utilisant des propriétés automatiques.

1. public class CompteBancaire

2. {

3. public string Titulaire { get; set; }

5. public double Solde { get; set; }

7. public string Devise { get; set; }

9. // ...

Cette syntaxe permet de gagner en concision si, comme le plus souvent, les accesseurs se contentent de donner accès à l'attribut sous-jacent sans rien faire de particulier.

DANGER ! Ne pas confondre un champ ou attribut (qui, par convention, commence par une lettre minuscule) avec une propriété au sens du C# (qui commence toujours par une lettre majuscule). Une propriété C# est un accesseur vers un champ.

III-C-3 - Avantages

Voici un exemple d'utilisation des accesseurs définis précédemment.

1. CompteBancaire comptePierre = new CompteBancaire("Pierre", 1000, "euros");

3. // utilisation des propriétés (accesseurs)

4. string titulaireCompte = comptePierre.Titulaire;  // en lecture (getter)

5. comptePierre.Solde = 500;                         // en écriture (setter)

6. comptePierre.Devise = "dollars";                  // en écriture (setter)

En remplaçant l'accès direct à un attribut par l'utilisation d'une méthode, un accesseur permet d'effectuer des contrôles supplémentaires : respect d'une plage de valeurs, accès en lecture uniquement, etc.

III-C-4 - Attributs en lecture seule

On observe dans l'exemple ci-dessus qu'il est à nouveau possible de modifier directement les données d'un compte. Pour interdire ces modifications tout en permettant de lire les informations d'un compte, il suffit de supprimer les accesseurs en écriture (setters).

1. public class CompteBancaire

2. {

3. private string titulaire;

4. private double solde;

5. private string devise;

7. public string Titulaire

8. {

9. get { return titulaire; }

10. }

12. public double Solde

13. {

14. get { return solde; }

15. }

17. public string Devise

18. {

19. get { return devise; }

20. }

22. // ...

Avec des propriétés automatiques, on peut seulement jouer sur le niveau de visibilité des accesseurs. La version 6 du langage C#, sortie en 2015, permet d'avoir réellement des propriétés automatiques en lecture seule (Plus de détails).

1. public class CompteBancaire

2. {

3. public string Titulaire { get; private set; }

4. // public string Titulaire { get; } // Incorrect avant C# 6 (.NET framework 4.6)

6. public double Solde { get; private set; }

8. public string Devise { get; private set; }

10. // ...

À présent, l'accès aux champs est toujours possible, mais toute tentative de modification des données sera refusée.

1. CompteBancaire comptePierre = new CompteBancaire("Pierre", 1000, "euros");

3. string titulaireCompte = comptePierre.Titulaire;  // OK : le getter existe

4. comptePierre.Solde = 500;                         // Erreur : pas de setter

5. comptePierre.Devise = "dollars";                  // Erreur : pas de setter

IV - Gestion des objets en mémoire

L'objectif de ce chapitre est de découvrir les spécificités des variables de type objet, ainsi que la notion de référence.

Les exemples de code associés sont disponibles en ligne.

IV-A - Préambule

Ce chapitre utilise une classe Cercle définie de la manière suivante.

1. // Modélise un cercle

2. public class Cercle

3. {

4. private double rayon;  // rayon du cercle

6. public double Rayon

7. {

8. get { return rayon; }

9. set { rayon = value; }

10. }

12. // constructeur

13. public Cercle(double rayon)

14. {

15. this.rayon = rayon;

16. }

18. // calcule le périmètre du cercle

19. public double CalculerPerimetre()

20. {

21. double perimetre = 2 * Math.PI * rayon;

22. return perimetre;

23. }

24. }

IV-B - Création d'un nouvel objet

Nous savons déjà que la création d'un nouvel objet se fait en deux étapes :

1. Déclaration de l'objet ;

2. Instanciation de l'objet.

Le code ci-dessous crée un nouvel objet monCercle de la classe Cercle.

Cercle monCercle;             // déclaration
monCercle = new Cercle(7.5);  // instanciation

On peut rassembler ces deux étapes sur une seule ligne. Cependant, il est important de bien les distinguer conceptuellement.

Cercle monCercle = new Cercle(7.5);   // déclaration et instanciation

Avant son instanciation, la « valeur » d'un objet en mémoire, observable au débogueur, est null. Elle correspond à un objet non instancié. L'instanciation permet de réserver une zone mémoire spécifique pour y stocker les données de l'objet.

IV-C - Affectation d'objets

IV-C-1 - La spécificité des objets

Nous allons découvrir une différence fondamentale entre le fonctionnement d'une variable de type prédéfini (types C# de base : int, double, bool, etc.) et le fonctionnement d'un objet.

Le code suivant utilise deux variables entières.

1. int nombre1;

2. nombre1 = 5;

3. int nombre2 = 3;

5. nombre2 = nombre1;

6. nombre1 = 10;

8. Console.WriteLine("nombre1 = " + nombre1);

9. Console.WriteLine("nombre2 = " + nombre2);

À la fin de son exécution, la variable nombre1 vaut 10 et la variable nombre2 vaut 5. Pas de surprise ici.

Écrivons un code similaire, mais qui utilise cette fois deux variables objets.

1. Cercle cercle1;           // déclaration

2. cercle1 = new Cercle(5);  // instanciation

3. Cercle cercle2 = new Cercle(3);   // déclaration et instanciation

5. cercle2 = cercle1;

6. cercle1.Rayon = 10;

8. Console.WriteLine("cercle1.Rayon = " + cercle1.Rayon); // 10

9. Console.WriteLine("cercle2.Rayon = " + cercle2.Rayon); // 10 (???)

À la fin de l'exécution, le rayon de cercle1 vaut 10 et celui de cercle2 vaut… 10.

Le résultat précédent devrait vous surprendre… On peut le compléter en observant ce qui se passe au débogueur. Voici le contenu des variables avant le changement du rayon de cercle1.

Et voici le contenu des variables après le changement du rayon de cercle1.

L'instruction cercle1.Rayon = 10 a simultanément modifié le rayon de cercle2. Comment expliquer ce mystère ?

IV-C-2 - La notion de référence

Revenons à notre exemple initial. Nous savons que la création d'une variable déclenche la réservation en mémoire d'une zone dédiée au stockage de sa valeur.

Les variables nombre1 et nombre2 correspondent à des zones mémoire distinctes. Toute modification de l'une (exemple : nombre1 = 10) n'a aucun impact sur l'autre.

Les objets fonctionnent de manière différente. Une variable objet ne stocke pas directement les données de l'objet, mais une référence vers l'emplacement mémoire où se trouvent ces données. De manière simpliste, on peut considérer une référence comme une adresse mémoire.

Une instanciation d'objet comme monCercle = new Cercle(7.5) provoque la réservation d'une zone mémoire pour stocker les données de l'objet, et affecte à la variable monCercle la référence vers cette zone mémoire.

Les observations précédentes ont maintenant une explication : l'affectation cercle2 = cercle1 provoque la copie de la référence de cercle1 dans cercle2. Après cette assignation, les deux variables « pointent » vers la même zone mémoire contenant les données du premier cercle.

DÉFINITION : l'affectation d'un objet à un autre ne déclenche pas la copie du contenu des objets. La référence (adresse mémoire) du premier objet est affectée au second, et les deux objets « pointent » vers la même zone mémoire.

Pour dupliquer l'objet lui-même et non sa référence, il faut utiliser d'autres techniques que vous découvrirez ultérieurement.

IV-C-3 - Types valeurs et types références

DÉFINITION : les types de données C# se répartissent en deux catégories :

- les types valeurs, où la valeur est directement stockée dans la variable ;

- les types références, où la variable stocke l'emplacement mémoire de la valeur.

Les types prédéfinis (int, double, etc.) sont des types valeurs. Les classes et les tableaux sont des types références.

Pourquoi avoir introduit la notion de référence dans le langage ? Essentiellement pour des raisons de performances. Contrairement aux types valeurs, un objet (ou une liste d'objets) peut occuper une taille non négligeable en mémoire. De trop nombreuses copies d'objets auraient donc pu ralentir l'exécution d'un programme. Grâce aux références, une affectation entre objets est quasi-instantanée : la seule information copiée est une adresse mémoire et non l'objet lui-même.

Le langage Java fait aussi la distinction entre types valeurs et références (Parameter passing in Java). Les objets en Python stockent également des références. Le langage C++ va encore plus loin : il distingue références et pointeurs.

Il est très important de savoir si une variable est de type valeurs ou de type références, car cela a un impact sur la comparaison d'objets et le passage d'un objet en paramètre.

IV-D - Comparaison d'objets

Étudions ce qui ce produit lorsque l'on compare deux variables de type valeurs, comme des entiers.

1. int nombre1;

2. nombre1 = 5;

4. int nombre2 = 3;

6. if (nombre1 == nombre2)

7. Console.WriteLine("nombre1 et nombre2 sont égaux");

8. else

9. Console.WriteLine("nombre1 et nombre2 sont différents");

11. nombre2 = 5;

13. if (nombre1 == nombre2)

14. Console.WriteLine("nombre1 et nombre2 sont égaux");

15. else

16. Console.WriteLine("nombre1 et nombre2 sont différents");

L'exécution de ce code indique d'abord que les variables sont différentes, puisqu'elles sont égales. Ceci est attendu puisque nous avons donné à nombre2 la même valeur que nombre1.

Écrivons un code similaire, mais qui utilise des objets.

1. Cercle cercle1;

2. cercle1 = new Cercle(5);

4. Cercle cercle2 = new Cercle(3);

6. if (cercle1 == cercle2)

7. Console.WriteLine("cercle1 et cercle2 sont égaux");

8. else

9. Console.WriteLine("cercle1 et cercle2 sont différents");

11. cercle2.Rayon = 5;

13. if (cercle1 == cercle2)

14. Console.WriteLine("cercle1 et cercle2 sont égaux");

15. else

16. Console.WriteLine("cercle1 et cercle2 sont différents");

Son exécution indique que les objets sont différents, et ils le sont bien. Et pourtant, leurs états (valeur du rayon) sont identiques… Pour comprendre, il est utile de représenter ce qui se passe en mémoire pendant l'exécution.

Le contenu des deux objets est bien identique, mais ceux-ci se trouvent à des emplacements mémoire distincts. Les variables cercle1 et cercle2 stockent des références vers deux objets distincts, qui dans ce cas particulier, ont le même état.

L'expression cercle1 == cercle2 compare les valeurs des références et non les états des objets eux-mêmes : elle renvoie toujours false dans notre exemple.

La notion « d'égalité d'objets » est donc plus complexe qu'il n'y paraît. Ici encore, il existe d'autres techniques pour comparer les objets et non leurs références.

IV-E - Passage d'un objet en paramètre

Il est important de comprendre la manière dont un objet est passé en paramètre à un sous-programme ou à une méthode. Pour cela, nous allons revenir en détail sur le fonctionnement du passage de paramètre.

Le code ci-dessous permet d'illustrer le passage d'un paramètre entier.

1. static void Main(string[] args)

2. {

3. int nombre = 5;

5. Console.WriteLine("Avant l'appel, nombre = " + nombre);

6. Augmenter(nombre);

7. Console.WriteLine("Après l'appel, nombre = " + nombre);

8. }

10. static void Augmenter(int unNombre)

11. {

12. Console.WriteLine("Avant l'augmentation, unNombre = " + unNombre);

13. unNombre = unNombre + 1;

14. Console.WriteLine("Après l'augmentation, unNombre = " + unNombre);

15. }

Nous savons expliquer ce résultat : il est dû au fait que les paramètres sont passés par valeur. Au moment de l'appel à Augmenter, la valeur de l'argument nombre est copiée dans l'espace mémoire du paramètre unNombre. La même valeur 5 est donc stockée à deux emplacements mémoire distincts. Toute modification de unNombre dans le sous-programme n'aura aucun impact sur la valeur de nombre dans le programme principal.

Voyons à présent ce qui se produit dans le cas d'objets.

1. static void Main(string[] args)

2. {

3. Cercle cercle = new Cercle(5);

5. Console.WriteLine("Avant l'appel, cercle.Rayon = " + cercle.Rayon);

6. AugmenterRayon(cercle);

7. Console.WriteLine("Après l'appel, cercle.Rayon = " + cercle.Rayon);

8. }

10. static void AugmenterRayon(Cercle unCercle)

11. {

12. Console.WriteLine("Avant l'augmentation, unCercle.Rayon = " + unCercle.Rayon);

13. unCercle.Rayon = unCercle.Rayon + 1;

14. Console.WriteLine("Après l'augmentation, unCercle.Rayon = " + unCercle.Rayon);

15. }

L'appel au sous-programme a modifié la valeur de l'objet ! Ceci semble contradictoire avec le mode de passage des paramètres par valeur, où l'argument est copié dans le paramètre.

Tentons de comprendre pourquoi avec une représentation mémoire de l'exécution.

Au moment de l'appel du sous-programme, la valeur de l'argument cercle, autrement dit la référence vers l'objet associé, est copiée dans le paramètre unCercle. Les variables cercle et unCercle contiennent la même référence et « pointent » donc vers le même emplacement mémoire. Cela explique que la modification faite dans le sous-programme ait un impact au niveau du programme principal : l'objet référencé est le même.

DÉFINITION : par défaut, tous les paramètres sont passés par valeur en C#.

- Dans le cas d'un type valeur, la valeur est copiée de l'argument vers le paramètre. Paramètre et argument concernent des zones mémoires différentes. Les modifications du paramètre n'ont pas d'impact sur l'argument.

- Dans le cas d'un type référence, la référence est copiée de l'argument vers le paramètre. Paramètre et argument concernent la même zone mémoire. Les modifications du paramètre modifient aussi l'argument.

IV-F - Destruction d'un objet

L'instanciation d'un objet provoque la réservation d'espace mémoire. Sans libération de cet espace une fois que l'objet sera devenu inutile, le risque existe d'une saturation de la mémoire de l'application, voire du système.

Dans certains langages comme le C++, la libération de la mémoire est de la responsabilité du programmeur, ce qui est source de problèmes complexes. Heureusement pour vous, le C# a suivi l'exemple de Java et dispose d'une fonctionnalité de libération mémoire automatique appelée ramasse-miettes (garbage collector).

Cela fonctionne de manière simple : tout objet, une fois qu'il n'est plus pointé par aucune référence, devient éligible pour la destruction (c'est-à-dire la libération de l'espace mémoire associé).

Dans l'exemple ci-dessus, l'objet initialement pointé par cercle2 n'est plus pointé par aucune référence et sera prochainement détruit automatiquement par le ramasse-miettes.

V - La relation d'association

L'objectif de ce chapitre est de découvrir pourquoi et comment mettre des objets en relation les uns avec les autres.

Les exemples de code associés sont disponibles en ligne.

V-A - Introduction aux relations entre objets

V-A-1 - La nécessité de relations

Comme nous l'avons déjà vu, la programmation orientée objet consiste à concevoir une application sous la forme de « briques » logicielles appelées des objets. Chaque objet joue un rôle précis et peut communiquer avec les autres objets. Les interactions entre les différents objets vont permettre à l'application de réaliser les fonctionnalités attendues.

Nous savons qu'un objet est une entité qui représente (modélise) un élément du domaine étudié : une voiture, un compte bancaire, un nombre complexe, une facture, etc. Un objet est toujours créé d'après un modèle, qui est appelé sa classe.

Sauf dans les cas les plus simples, on ne pourra pas modéliser fidèlement le domaine étudié en se contentant de concevoir une seule classe. Il faudra définir plusieurs classes et donc instancier des objets de classes différentes. Cependant, ces objets doivent être mis en relation afin de pouvoir communiquer.

V-A-2 - Contexte d'exemple

Reprenons notre classe modélisant un compte bancaire, issue d'un précédent chapitre.

1. public class CompteBancaire

2. {

3. private string titulaire;

4. private double solde;

5. private string devise;

7. public string Titulaire

8. {

9. get { return titulaire; }

10. }

12. public double Solde

13. {

14. get { return solde; }

15. }

17. public string Devise

18. {

19. get { return devise; }

20. }

22. public CompteBancaire(string leTitulaire, double soldeInitial, string laDevise)

23. {

24. titulaire = leTitulaire;

25. solde = soldeInitial;

26. devise = laDevise;

27. }

29. public void Crediter(double montant)

30. {

31. solde += montant;

32. }

34. public void Debiter(double montant)

35. {

36. solde -= montant;

37. }

39. public string Decrire()

40. {

41. return "Le solde du compte de " + titulaire + " est de " + solde + " " + devise;

42. }

43. }

V-A-3 - Evolution des besoins

On souhaite à présent enrichir notre modélisation orientée objet en incluant des informations détaillées sur le titulaire d'un compte : son nom, son prénom et son numéro de client.

Une première idée serait d'ajouter dans notre classe les attributs correspondants. C'est une mauvaise idée : les données d'un client seraient dupliquées dans chacun de ses comptes. Et le rôle de la classe CompteBancaire est de modéliser un compte, pas un client.

La bonne solution est de créer une nouvelle classe représentant un client.

V-A-4 - Modélisation du client

On crée la classe Client dont le rôle sera de représenter les clients titulaires d'au moins un compte.

1. // Modélise un client

2. public class Client

3. {

4. private int numero;    // Numéro de compte

5. private string nom;    // Nom

6. private string prenom; // Prénom

8. public int Numero

9. {

10. get { return numero; }

11. }

13. public string Nom

14. {

15. get { return nom; }

16. }

18. public string Prenom

19. {

20. get { return prenom; }

21. }

23. public Client(int leNumero, string leNom, string lePrenom)

24. {

25. numero = leNumero;

26. nom = leNom;

27. prenom = lePrenom;

28. }

29. }

On retrouve dans cette classe, sous forme d'attributs, les données caractérisant un client : numéro, nom et prénom. On pourra ensuite instancier des objets de cette classe pour représenter les clients de la banque.

Client pierre = new Client(123456, "Kiroul", "Pierre");
Client paul = new Client(987654, "Ochon", "Paul");

À ce stade, nous avons d'un côté des comptes, de l'autre des clients, mais pas encore de relations entre eux. Plus précisément, il faudrait pouvoir modéliser l'information « ce compte a pour titulaire ce client ». Pour cela, on modifie le type de l'attribut titulaire dans CompteBancaire : on remplace le type string par le type Client. Il faut également modifier l'accesseur et le constructeur pour faire le même changement.

1. class CompteBancaire

2. {

3. private Client titulaire;  // type string => type Client

5. public Client Titulaire

6. {

7. get { return titulaire; }

8. }

10. public CompteBancaire(Client leTitulaire, double soldeInitial, string laDevise)

11. {

12. titulaire = leTitulaire;

13. solde = soldeInitial;

14. devise = laDevise;

15. }

17. // ...

La création d'un nouveau compte nécessite de passer en paramètre le client titulaire.

1. Client pierre = new Client(123456, "Kiroul", "Pierre");

2. Client paul = new Client(987654, "Ochon", "Paul");

4. CompteBancaire compte1 = new CompteBancaire(pierre, 500, "euros");

5. CompteBancaire compte2 = new CompteBancaire(paul, 1000, "euros");

7. Console.WriteLine("N° du titulaire du compte 1 : " + compte1.Titulaire.Numero);

8. Console.WriteLine("Nom du titulaire du compte 2 : " + compte2.Titulaire.Nom);

La propriété C# Titulaire de la classe CompteBancaire renvoie une instance de la classe Client. On peut ensuite utiliser ses propriétés et ses méthodes, comme pour n'importe quel autre objet.

On souhaite à présent afficher la description de chaque compte.

1. Client pierre = new Client(123456, "Kiroul", "Pierre");

2. Client paul = new Client(987654, "Ochon", "Paul");

4. CompteBancaire compte1 = new CompteBancaire(pierre, 500, "euros");

5. CompteBancaire compte2 = new CompteBancaire(paul, 1000, "euros");

7. Console.WriteLine(compte1.Decrire());

8. Console.WriteLine(compte2.Decrire());

Le résultat d'exécution ci-dessus est étrange mais logique. Pour le comprendre, revenons au code source de la méthode Decrire.

1. public string Decrire()

2. {

3. string description = "Le solde du compte de " + titulaire + " est de " + solde + " " + devise;

4. return description;

5. }

Ici on concatène l'attribut titulaire, instance de la classe Client, à une chaîne de caractères pour créer la description du compte. Le langage C# ne sait pas comment représenter un client sous forme textuelle et il renvoie le nom complet de la classe Client (ici, Association correspond à l'espace de noms dans lequel est définie la classe Client). Il est donc nécessaire de modifier la méthode Decrire afin d'afficher toutes ses propriétés.

1. public string Decrire()

2. {

3. string descriptionTitulaire = titulaire.Prenom + " " + titulaire.Nom;

4. string description = "Le solde du compte de " + descriptionTitulaire + " est de " + solde + " " + devise;

5. return description;

6. }

REMARQUE : il existe une meilleure solution pour obtenir une représentation textuelle d'un objet. Elle sera étudiée dans un prochain chapitre.

Dans cet exemple, on a mis en relation un objet de la classe CompteBancaire avec un objet de la classe Client. En terminologie objet, on dit que l'on a créé une association entre les deux classes.

V-B - Associations entre classes

V-B-1 - Définition

L'exemple précédent a permis de mettre en relation un compte bancaire et son client titulaire. Grâce à cette association, nous avons modélisé la relation « un compte bancaire a un titulaire ».

DÉFINITION : une association modélise une relation entre classes de type « a un » ou « a plusieurs ». Un compte bancaire a un titulaire, un livre a plusieurs pages, etc.

AVERTISSEMENT : ne pas confondre avec une relation de type « est un », modélisée par l'héritage (voir chapitre suivant).

On représente l'association graphiquement en UML par un trait continu entre les deux classes concernées.

On observe que l'attribut titulaire de CompteBancaire n'est plus listé dans cette classe, mais représenté au-dessus du trait d'association, côté classe Client. Il s'agit du rôle que jouent les instances de Client dans l'association : un client est le titulaire d'un compte bancaire.

La flèche qui pointe vers Client indique le sens de navigation de l'association.

Une relation d'association implique un lien entre les classes concernées. Elles ne peuvent plus fonctionner indépendamment l'une de l'autre. On parle également de couplage entre les classes.

V-B-2 - Multiplicités d'une association

Notre définition de la classe CompteBancaire implique qu'un objet de cette classe a un et un seul titulaire. En revanche, rien n'empêche que plusieurs comptes bancaires aient le même client comme titulaire.

Autrement dit, notre modélisation conduit aux règles ci-dessous :

- une instance de CompteBancaire est associée à une seule instance de Client ;

- une instance de Client peut être associée à un nombre quelconque d'instances de CompteBancaire.

Cela se représente graphiquement par l'ajout de multiplicités à chaque extrémité de l'association.

DÉFINITION : la multiplicité située à une extrémité d'une association UML indique à combien d'instances de la classe une instance de l'autre classe peut être liée.

ATTENTION : les multiplicités UML se lisent dans le sens inverse des cardinalités Merise.

Le tableau ci-dessous rassemble les principales multiplicités utilisées en UML.

Multiplicité

Signification

0..1

Zéro ou un

1

Un

*

De zéro à plusieurs

1..*

De un à plusieurs

DÉFINITION : la multiplicité par défaut (en l'absence de mention explicite à l'extrémité d'une association) est 1.

En étudiant leurs multiplicités, on peut trouver la sémantique des associations ci-dessous.

- Une personne possède zéro ou plusieurs ordinateurs.

- Un ordinateur appartient à une ou plusieurs personnes.

- Une personne possède zéro ou un ordinateur.

- Un ordinateur appartient à une personne.

V-B-3 - Navigabilité d'une association

Revenons à notre exemple des comptes bancaires associés à leur titulaire.

À partir d'une instance de la classe CompteBancaire, il est possible d'accéder à son titulaire, instance de la classe Client. Par contre, à partir d'une instance de Client, rien ne permet de retrouver les comptes dont il est titulaire. L'association entre CompteBancaire et Client ne peut être navigable que dans un seul sens, de CompteBancaire vers Client.

DÉFINITION : une association navigable dans un seul sens est une association unidirectionnelle.

Dans le diagramme UML, le sens de navigation est exprimé par une flèche qui pointe vers la classe vers laquelle on peut naviguer (ici la classe Client).

AVERTISSEMENT : ne pas confondre la flèche de la navigabilité unidirectionnelle avec la flèche pleine de l'héritage.

On pourrait souhaiter que l'association soit également navigable mais dans le sens inverse, c'est-à-dire que l'on puisse retrouver les comptes dont un client est titulaire. Pour cela, il faut modifier la classe Client. Il est nécessaire d'y ajouter un attribut qui stockera les comptes bancaires dont un client est titulaire. Comme un client peut être titulaire de plusieurs comptes, cet attribut sera une liste d'instances de la classe CompteBancaire.

1. public class Client

2. {

3. // ...

4. private List<CompteBancaire> comptes;

6. public Client(int numero, string nom, string prenom)

7. {

8. comptes = new List<CompteBancaire>();

9. // ...

10. }

12. public List<CompteBancaire> Comptes

13. {

14. get { return comptes; }

15. }

17. // ...

L'utilisation des classes reflète les changements effectués : il faut maintenant ajouter à un client les comptes dont il est titulaire.

1. Client pierre = new Client(123456, "Kiroul", "Pierre");

2. Client paul = new Client(987654, "Ochon", "Paul");

4. // association entre pierre et compte1

5. CompteBancaire compte1 = new CompteBancaire(pierre, 500, "euros");

6. pierre.Comptes.Add(compte1);

8. // association entre paul et compte2

9. CompteBancaire compte2 = new CompteBancaire(paul, 1000, "euros");

10. paul.Comptes.Add(compte2);

DÉFINITION : une association navigable dans les deux sens est une association bidirectionnelle.

Dans un diagramme de classes UML, l'absence de flèche sur le lien d'association indique qu'elle est bidirectionnelle et donc navigable dans les deux sens.

CONSEIL : en pratique, une association bidirectionnelle est plus complexe à coder : il faut penser à mettre à jour les attributs de chaque classe pour qu'elle reste cohérente. On privilégiera le plus souvent les associations unidirectionnelles en choisissant le sens de navigation le plus utile.

V-C - Types particuliers d'associations

V-C-1 - L'agrégation

Prenons l'exemple d'un courrier électronique (courriel). Il peut éventuellement contenir une ou plusieurs pièces jointes. Les pièces jointes font partie du courriel. Un courriel et ses pièces jointes sont liés par une relation de type « se compose de », « composant/composé » ou encore « tout/partie ».

Dans le cadre d'une modélisation objet, on représente cette association particulière par un lien dont une extrémité (celle du composé) comporte un losange vide. On la nomme agrégation.

DÉFINITION : une agrégation est une association qui modélise une relation « se compose de ».

REMARQUES

- Un composant (ici, une pièce jointe) peut être partagé par plusieurs composés (ici, des courriels).

- La traduction en code source d'une agrégation est identique à celle d'une association.

V-C-2 - La composition

Un livre se compose d'un certain nombre de pages. On pourrait modéliser le lien livre-page par une agrégation. Cependant, les cycles de vie des objets associés sont liés : si un livre disparaît, toutes les pages qui le composent disparaissent également. Ce n'est pas le cas pour l'exemple précédent : une pièce jointe n'est pas détruite si le courriel auquel elle est jointe disparaît.

Pour traduire une relation composant/composé dont les cycles de vie sont liés, on représente l'association correspondante avec un losange plein du côté du composé. On la nomme composition.

DÉFINITION : une composition est une agrégation entre objets dont les cycles de vie sont liés.

REMARQUES

- Un composant (ici, une page) ne peut pas être partagé par plusieurs composés (ici, des livres). Par conséquent, la multiplicité côté composé est toujours 1.

- La traduction en code source d'une composition ressemble à celle d'une agrégation. Comme les cycles de vie des objets sont liés, les composants sont parfois instanciés par le constructeur du composé.

V-D - Traduction en code d'une association

Une association entre deux classes se traduit par l'ajout d'attributs. Les classes associées possèdent en attribut une ou plusieurs références vers l'autre classe.

La présence ou l'absence de références dans une classe dépend de la navigabilité de l'association. Si des références sont présentes, leur nombre dépend des multiplicités de l'association.

Soit le diagramme UML ci-dessous.

L'association est navigable de Ordinateur vers Personne. Il y aura donc une ou plusieurs références à Personne dans la classe Ordinateur, mais pas de référence à Ordinateur dans la classe Personne.

La multiplicité au niveau de Personne est la multiplicité par défaut, 1. Un ordinateur appartient à une et une seule personne. Il y a aura donc une référence à Personne dans la classe Ordinateur. Afin de respecter le rôle défini pour la classe Personne dans l'association, l'attribut correspondant est nommé proprietaire.

1. class Ordinateur

2. {

3. private Personne proprietaire;

4. // ...

DÉFINITION : une multiplicité 1 ou 0..1 se traduit par l'ajout d'une seule référence comme attribut.

Soit le diagramme UML ci-dessous.

L'association est navigable de Livre vers Page. Il y aura donc une ou plusieurs références à Page dans la classe Livre, mais pas de référence à Livre dans la classe Page.

La multiplicité au niveau de Page est 1..*. Un livre est composé de une ou plusieurs pages. L'attribut ajouté dans Livre doit permettre de stocker un nombre quelconque d'instances de Page. On utilise donc une liste d'objets de type Page. Afin de respecter le rôle défini pour la classe Page dans l'association, l'attribut correspondant est nommé pages.

1. class Livre

2. {

3. private List<Page> pages;

4. // ...

DÉFINITION : une multiplicité * ou 1..* se traduit par l'ajout d'une liste de références comme attribut.

VI - La relation d'héritage

L'héritage est l'un des mécanismes fondamentaux de la POO. Il permet de créer des classes à partir de classes existantes. L'objectif de ce chapitre est de découvrir son fonctionnement.

Les exemples de code associés sont disponibles en ligne.

VI-A - Premiers pas

VI-A-1 - Exemple d'utilisation

Reprenons la classe CompteBancaire utilisée dans un précédent chapitre.

1. // Définit un compte bancaire

2. public class CompteBancaire

3. {

4. private string titulaire; // Titulaire du compte

5. private double solde;     // Solde du compte

6. private string devise;    // Devise du compte

8. public string Titulaire

9. {

10. get { return titulaire; }

11. }

13. public double Solde

14. {

15. get { return solde; }

16. }

18. public string Devise

19. {

20. get { return devise; }

21. }

23. // Constructeur

24. public CompteBancaire(string leTitulaire, double soldeInitial, string laDevise)

25. {

26. titulaire = leTitulaire;

27. solde = soldeInitial;

28. devise = laDevise;

29. }

31. // Ajoute un montant au compte

32. public void Crediter(double montant)

33. {

34. solde = solde + montant;

35. }

37. // Retire un montant au compte

38. public void Debiter(double montant)

39. {

40. solde = solde - montant;

41. }

43. // Renvoie la description du compte

44. public string Decrire()

45. {

46. string description = "Le solde du compte de " + titulaire + " est de " + solde + " " + devise;

47. return description;

48. }

49. }

Supposons maintenant que nous ayons à gérer un nouveau type de compte : le compte épargne. Comme un compte classique, un compte épargne possède un titulaire, un solde et une devise. Sa spécificité est qu'il permet d'appliquer des intérêts à l'argent déposé sur le compte.

Bien sûr, il serait possible de concevoir une classe CompteEpargne totalement distincte de la classe CompteBancaire. Cependant, on constate qu'un compte épargne possède toutes les caractéristiques d'un compte bancaire, plus des caractéristiques spécifiques. Nous allons donc définir un compte épargne par héritage de la définition d'un compte bancaire.

1. public class CompteEpargne : CompteBancaire

2. {

3. private double tauxInteret;

5. public CompteEpargne(string leTitulaire, double soldeInitial, string laDevise, double leTauxInteret)

6. : base(leTitulaire, soldeInitial, laDevise)

7. // appel du constructeur de la classe CompteBancaire

8. // le mot-clé "base" permet d'accéder à la classe parente

9. {

10. tauxInteret = leTauxInteret;

11. }

13. // Calcule et ajoute les intérêts au solde du compte

14. public void AjouterInterets()

15. {

16. // ... (détaillé plus bas)

17. }

18. }

Dans la déclaration class CompteEpargne : CompteBancaire, les deux-points spécifient que la classe CompteEpargne hérite de la classe CompteBancaire.

REMARQUE : d'autres langages comme Java ou PHP utilisent le mot-clé extends plutôt que le symbole : pour indiquer une relation d'héritage entre deux classes.

On observe que le constructeur de CompteEpargne fait appel au constructeur de CompteBancaire pour en initialiser les attributs. Le mot-clé base désigne la classe parente. Le constructeur de CompteEpargne initialise également l'attribut tauxInteret qui lui est propre.

VI-A-2 - Représentation graphique

Le formalisme graphique UML décrit la relation d'héritage entre deux classes par une flèche pleine allant de la classe dérivée à la classe de base (les propriétés C# de CompteBancaire ont été masquées afin d'alléger le diagramme).

VI-A-3 - Définition

DÉFINITION : l'héritage est un mécanisme objet qui consiste à définir une classe à partir d'une classe existante. Une classe héritant d'une autre, possède les caractéristiques de la classe initiale et peut définir ses propres éléments.

La nouvelle classe (ou classe dérivée) correspond à une spécialisation de la classe de base (appelée classe parente ou superclasse). On dit que l'héritage crée une relation de type est un entre les classes. Dans notre exemple, un compte épargne est un type particulier de compte bancaire.

ATTENTION : le constructeur d'une classe dérivée doit obligatoirement faire explicitement appel au constructeur de la classe parente lorsque celui-ci prend des paramètres. C'est le cas dans notre exemple.

VI-A-4 - Avantages

Grâce à la relation d'héritage, un objet de la classe CompteEpargne peut utiliser les fonctionnalités de la classe CompteBancaire sans avoir à les redéfinir. On peut donc débiter ou créditer un compte épargne exactement comme un compte bancaire.

1. double tauxInteret = 0.05;  // taux d'intérêt : 5%

2. CompteEpargne comptePaul = new CompteEpargne("paul", 100, "dollars", tauxInteret);

4. // appel des méthodes de CompteBancaire sur le compte épargne

5. comptePaul.Debiter(1000);

6. comptePaul.Crediter(1500);

7. Console.WriteLine(comptePaul.Decrire()); // Affiche 600 $

Par contre, le calcul des intérêts (méthode AjouterInterets) ne peut se faire que sur un objet de la classe CompteEpargne. L'héritage est une relation unidirectionnelle.

1. // OK : comptePaul est un compte épargne

2. comptePaul.AjouterInterets();

3. Console.WriteLine(comptePaul.Decrire());

5. CompteBancaire comptePierre = new CompteBancaire("pierre", 100, "dollars");

6. // Erreur : comptePierre est un compte bancaire, pas un compte épargne

7. comptePierre.AjouterInterets();

Grâce à l'héritage, il est possible de réutiliser les fonctionnalités d'une classe existante en la spécialisant. Il est également possible de spécialiser une classe dérivée.

On voit bien tous les avantages que l'héritage peut apporter : gain de temps de développement, amélioration de la qualité du code, création de hiérarchies de classes reflétant précisément le domaine d'étude, etc.

VI-A-5 - Héritage et encapsulation

Nous avons volontairement laissé de côté un point délicat. La méthode AjouterInterets de la classe CompteEpargne, qui doit ajouter les intérêts au solde, n'est pas encore définie. En voici une première version.

1. public class CompteEpargne : CompteBancaire

2. {

3. // ...

5. public void AjouterInterets()

6. {

7. // calcul des intérêts sur le solde

8. double interets = solde * tauxInteret;

9. // ajout des intérêts au solde

10. solde += interets;

11. }

12. }

Cependant, le compilateur nous signale l'erreur suivante.

Dans cet exemple, la classe dérivée CompteEpargne tente d'accéder à l'attribut solde qui appartient à la classe de base CompteBancaire. Cependant, cet attribut est défini avec le niveau de visibilité private ! Cela signifie qu'il n'est utilisable que dans la classe où il est défini, et non dans les classes dérivées.

Pour interdire l'accès à un membre d'une classe (attribut, propriété C# ou méthode) depuis l'extérieur, tout en permettant son utilisation par une classe dérivée, il faut associer à ce membre un niveau de visibilité intermédiaire : protected.

1. public class CompteBancaire

2. {

3. private string titulaire;

4. protected double solde;   // attribut protégé

5. private string devise;

7. // ...

Une autre solution à ce problème consiste à laisser le champ solde privé et à définir un accesseur protégé pour modifier le solde depuis les méthodes de la classe CompteEpargne.

1. public class CompteBancaire

2. {

3. private string titulaire;

4. private double solde;

5. private string devise;

7. // ...

9. public double Solde

10. {

11. get { return solde; }             // accesseur public pour la lecture

12. protected set { solde = value; }  // mutateur protégé pour la modification

13. }

14. // public string Solde { get; protected set; } // Equivalent avec une propriété automatique

16. // ...

Bien entendu, il faut alors utiliser le mutateur Solde et non plus l'attribut solde pour accéder au solde depuis la classe dérivée.

1. public class CompteEpargne : CompteBancaire

2. {

3. // ...

5. public void AjouterInterets()

6. {

7. // utilisation du mutateur Solde pour accéder au solde du compte

8. double interets = Solde * tauxInteret;

9. Solde += interets;

10. }

11. }

Le tableau ci-dessous rassemble les trois niveaux de visibilité utilisables.

Visibilité

Classe

Classes dérivées

Extérieur

public

X

X

X

protected

X

X

private

X

VI-B - Polymorphisme

Afin de gagner en généricité, on peut appliquer le même code à des objets de types différents, lorsque les classes de ces objets sont liées par héritage. C'est le principe du polymorphisme.

Prenons l'exemple d'une liste de comptes bancaires dont l'un est un compte épargne.

1. CompteBancaire compte1 = new CompteBancaire("Pierre", 300, "euros");

2. CompteEpargne compte2 = new CompteEpargne("Paul", 200, "dollars", 0.05);

3. CompteBancaire compte3 = new CompteBancaire("Jacques", 50, "euros");

5. List<CompteBancaire> listeComptes = new List<CompteBancaire>();

6. listeComptes.Add(compte1);

7. listeComptes.Add(compte2); // Est-ce bien un compte bancaire ?

8. listeComptes.Add(compte3);

10. foreach (CompteBancaire compte in listeComptes)

11. Console.WriteLine(compte.Decrire());

Ce code fonctionne ! Un compte épargne « est un » compte bancaire. On peut donc stocker un compte épargne dans une liste de comptes bancaires. On peut même appeler la méthode Decrire sur chacun des éléments de la liste de comptes bancaires. C'est un exemple très simple de ce que l'on appelle le polymorphisme.

DÉFINITION : utiliser le polymorphisme consiste à écrire un code générique qui pourra s'appliquer à des objets appartenant à des classes différentes.

Le polymorphisme rend le code plus concis, plus élégant et plus sûr. C'est un mécanisme à utiliser dès que l'occasion se présente. Il peut être enrichi grâce aux mécanismes que nous allons découvrir maintenant.

VI-C - Classes et méthodes abstraites

VI-C-1 - Évolution des besoins

Nous obtenons les précisions suivantes sur notre domaine d'étude :

- un compte bancaire est soit un compte courant, soit un compte épargne ;

- un compte courant se caractérise par le numéro de la carte bancaire qui lui est associée, ainsi que par un découvert maximal autorisé. Tout retrait qui ferait passer le nouveau solde en dessous du découvert maximal est interdit et non effectué ;

- on ne peut retirer en une seule fois plus de la moitié du solde d'un compte épargne.

VI-C-2 - Création d'une classe abstraite

Notre modélisation objet du domaine doit refléter ces évolutions. Jusqu'à présent, un compte épargne hérite de toutes les caractéristiques d'un compte bancaire et en ajoute d'autres (taux d'intérêt). Nous voyons apparaître des éléments spécifiques à un compte courant : numéro de CB, découvert maximal. Il serait maladroit d'ajouter ces attributs à la classe CompteBancaire, puisqu'ils seraient hérités par la classe CompteEpargne alors qu'ils ne la concernent pas.

La bonne solution est de placer dans la classe CompteBancaire les éléments communs à tous les types de comptes. Deux autres classes, CompteCourant et CompteEpargne, héritent de CompteBancaire afin d'intégrer ces éléments communs. Chaque classe dérivée contient ce qui est spécifique à chaque type de comptes. Le diagramme ci-dessous reflète cette modélisation.

Une instance de CompteCourant ou de CompteEpargne représente respectivement un compte courant ou un compte épargne. Ce sont des concepts concrets du domaine d'étude.

En revanche, que représenterait une instance de CompteBancaire ? Un compte bancaire est soit un compte courant, soit un compte épargne. Un compte bancaire en général n'a pas d'existence concrète. La classe CompteBancaire est une abstraction destinée à factoriser ce qui est commun à tous les comptes, mais pas à être instanciée.

Pour refléter cela, on définit la classe CompteBancaire comme étant abstraite.

1. public abstract class CompteBancaire

2. {

3. // ...

4. }

En C# (ainsi qu'en Java et en C++), le mot-clé abstract permet de préciser qu'une classe est abstraite. Dans un diagramme de classe UML, le nom d'une classe abstraite est écrit en italiques.

DÉFINITION : une classe abstraite définit un concept abstrait, incomplet ou théorique. Elle rassemble des éléments communs à plusieurs classes dérivées. Elle n'est pas destinée à être instanciée.

Par opposition aux classes abstraites, les classes instanciables sont parfois appelées classes concrètes.

VI-C-3 - Modélisation des caractéristiques

Il est maintenant nécessaire de compléter nos classes en y ajoutant les attributs reflétant les caractéristiques (données) des éléments du domaine.

Tout compte possède un titulaire, un solde et une devise. Un compte courant se caractérise par un numéro de carte bancaire et un découvert maximal autorisé. Quant à un compte épargne, il est défini par son taux d'intérêt. On peut donc imaginer la modélisation ci-dessous pour les attributs des classes.

VI-C-4 - Modélisation du comportement

Les constructeurs de chaque classe sont simples à définir : ils doivent initialiser leurs attributs. Les constructeurs de CompteCourant et CompteEpargne feront appel à celui de CompteBancaire afin d'initialiser les attributs communs.

Les opérations que l'on souhaite appliquer aux comptes sont :

- le dépôt d'argent (crédit) ;

- le retrait d'argent (débit) ;

- la description du compte.

Le dépôt d'argent fonctionne de la même manière pour tous les types de comptes : le solde est simplement augmenté du montant. Ce n'est pas le cas du débit. Chaque type de compte peut être débité, mais de manière très différente :

- un compte courant autorise un découvert maximal ;

- un compte épargne limite le montant du retrait par rapport au solde.

VI-C-5 - Déclaration d'une méthode abstraite

Pour traiter la problématique du débit d'argent, il faudrait pouvoir déclarer une opération de débit dans la superclasse CompteBancaire et laisser les classes dérivées définir comment cette opération est effectuée.

Il existe une technique pour obtenir ce résultat : la définition d'une méthode abstraite.

On modifie la classe CompteBancaire pour rendre la méthode Debiter abstraite, en la faisant précéder du mot-clé abstract.

1. public abstract class CompteBancaire

2. {

3. private string titulaire;

4. private double solde;

5. private string devise;

7. public CompteBancaire(string leTitulaire, double soldeInitial, string laDevise)

8. {

9. titulaire = leTitulaire;

10. solde = soldeInitial;

11. devise = laDevise;

12. }

14. public double Solde

15. {

16. get { return solde; }

17. protected set { solde = value; }

18. }

20. public string Devise

21. {

22. get { return devise; }

23. }

25. public string Titulaire

26. {

27. get { return titulaire; }

28. }

30. public void Crediter(double montant)

31. {

32. solde += montant;

33. }

35. // La méthode Debiter est maintenant abstraite

36. public abstract void Debiter(double montant);

38. public string Decrire()

39. {

40. return "Le solde du compte de " + titulaire + " est de " + solde + " " + devise;

41. }

42. }

Remarquez l'absence d'accolades ouvrantes et fermantes après le nom Debiter, remplacées par un simple ;. La méthode est déclarée mais pas définie. Ce sera aux classes qui héritent de CompteBancaire de fournir une implémentation respectant la signature de la méthode.

DÉFINITIONS

- Le terme implémenter signifie : rendre concret, traduire en code.

- La signature d'une méthode est constituée de son nom et de la liste de ses paramètres.

ATTENTION : il faut bien faire la distinction entre une déclaration et une définition.

- Déclarer une méthode, c'est dire ce que doit faire cette méthode, sans dire comment.

- Définir une méthode, c'est dire comment cette méthode fait ce qu'elle doit faire, autrement dit l'implémenter.

On constate que notre classe CompteCourant actuelle ne compile plus : il est obligatoire d'y redéfinir la méthode Debiter.

VI-C-6 - Redéfinition d'une méthode abstraite

On ajoute dans la classe CompteCourant la méthode Debiter requise, avec la même signature que celle de la superclasse.

1. public class CompteCourant : CompteBancaire

2. {

3. private string numeroCB;

4. private double decouvertMaxi;

6. // Constructeur

7. public CompteCourant(string leTitulaire, double soldeInitial, string laDevise, string numeroCB, double decouvertMaxi)

8. : base(leTitulaire, soldeInitial, laDevise)  // appel au constructeur de CompteBancaire

9. {

10. this.numeroCB = numeroCB;

11. this.decouvertMaxi = decouvertMaxi;

12. }

14. // Redéfinition de la méthode Debiter

15. public override void Debiter(double montant)

16. {

17. // on n'effectue le débit que si le solde final reste supérieur au découvert

18. if (Solde - montant >= decouvertMaxi)

19. Solde -= montant;

20. }

21. }

En C#, la redéfinition d'une méthode abstraite doit être précédée du mot-clé override.

Suivant le même principe, on complète la définition de la classe CompteEpargne pour préciser de quelle manière un compte épargne est débité.

1. public class CompteEpargne : CompteBancaire

2. {

3. // ...

5. // Redéfinition de la méthode Debiter

6. public override void Debiter(double montant)

7. {

8. // Le montant maximal d'un retrait est la moitié du solde actuel

9. if (montant <= Solde / 2)

10. Solde -= montant;

11. }

12. }

On peut maintenant instancier nos classes dérivées et tester leurs fonctionnalités.

1. CompteCourant compteCourant = new CompteCourant("Pierre", 250, "dollars", "1234 5678 9123 4567", -500);

2. compteCourant.Debiter(300);

3. compteCourant.Debiter(500);

4. Console.WriteLine(compteCourant.Decrire());

On constate que le second retrait de 500 dollars n'a pas eu lieu, puisqu'il aurait fait passer le solde en dessous du découvert maximal autorisé.

REMARQUE : le programme principal n'est pas informé de l'échec du second retrait, ce qui peut laisser croire que ce retrait a réussi. Nous découvrirons prochainement le mécanisme de remontée d'erreur que l'on utilise dans ces cas de figure.

1. CompteEpargne compteEpargne = new CompteEpargne("Paul", 1000, "euros", 0.04);

2. compteEpargne.Debiter(300);

3. compteEpargne.Debiter(500);

4. Console.WriteLine(compteEpargne.Decrire());

Ici encore, le second retrait n'a pas eu lieu : son montant est supérieur à la moitié du solde (700 euros au moment de l'appel).

VI-C-7 - Bilan

DÉFINITION : une méthode abstraite (mot-clé abstract) déclare un comportement sans le définir. Elle doit être redéfinie (mot-clé override) dans toutes les classes dérivées.

Une classe comportant au moins une méthode abstraite est nécessairement une classe abstraite.

Déclarer une méthode abstraite dans une superclasse permet d'imposer à toutes les classes dérivées de fournir une implémentation de cette méthode. Ainsi, on demande à ces classes de fournir un certain comportement tout en les laissant choisir comment elles procèdent.

VI-D - Méthodes virtuelles

VI-D-1 - Évolution du contexte

Intéressons-nous à la description d'un compte. Elle devrait renvoyer les données communes (solde, devise) et les données spécifiques au type (numéro de CB, découvert maximal ou taux d'intérêt), ce qui n'est pas le cas actuellement : seuls les attributs de la classe abstraite CompteBancaire sont affichés.

Dans ce cas de figure, on voudrait pouvoir utiliser le comportement commun (celui de CompteBancaire) et le compléter par un comportement particulier à chaque sous-classe. Pour cela, nous pouvons rendre la méthode Decrire virtuelle.

VI-D-2 - Mise en œuvre

Modifiez la définition de Decrire dans CompteBancaire pour ajouter le mot virtual. Le reste de sa définition ne change pas.

1. public abstract class CompteBancaire

2. {

3. // ...

5. public virtual string Decrire()

6. { // ... }

7. }

En faisant cela, on indique au compilateur que cette méthode est virtuelle, autrement dit susceptible d'être redéfinie dans une classe dérivée. C'est justement ce que nous allons faire dans CompteCourant et CompteEpargne pour intégrer à la description les données spécifiques à chaque type de comptes.

1. public class CompteCourant : CompteBancaire

2. {

3. // ...

5. // Redéfinition de la méthode Decrire

6. public override string Decrire()

7. {

8. return base.Decrire() + ". Son numéro CB est " + numeroCB +

9. " et son découvert maxi est de " + decouvertMaxi + " " + Devise + ".";

10. }

11. }

13. public class CompteEpargne : CompteBancaire

14. {

15. // ...

17. // Redéfinition de la méthode Decrire

18. public override string Decrire()

19. {

20. return base.Decrire() + ". Son taux d'intérêt est de " + (tauxInteret * 100) + "%.";

21. }

22. }

Le mot-clé base permet d'accéder aux membres de la classe de base depuis une méthode d'une classe dérivée. Ici, base.Decrire() appelle la méthode Decrire de CompteBancaire.

Nous obtenons le résultat suivant.

À présent, chaque type de comptes dispose d'une description spécifique, et la description des informations communes à tous les comptes (titulaire, solde et devise) se fait sans aucune duplication de code.

VI-D-3 - Bilan

DÉFINITION : une méthode virtuelle (virtual) fournit un comportement par défaut dans une classe. Elle peut être redéfinie (override) dans une classe dérivée.

Grâce aux méthodes virtuelles, on pousse encore plus loin les possibilités du polymorphisme.

ATTENTION : ne pas confondre méthode virtuelle et méthode abstraite :

- une méthode virtuelle définit un comportement, éventuellement redéfini ;

- une méthode abstraite déclare un comportement, obligatoirement redéfini.

VII - Compléments sur l'écriture de classes

L'objectif de ce chapitre est de découvrir comment enrichir la définition de nos classes en utilisant certaines possibilités des langages à objets.

Les exemples de code associés sont disponibles en ligne.

VII-A - Contexte d'exemple

Nous allons utiliser la classe CompteBancaire ci-dessous, issue d'un précédent chapitre.

1. // Définit un compte bancaire

2. public class CompteBancaire

3. {

4. private string titulaire; // Titulaire du compte

5. private double solde;     // Solde du compte

6. private string devise;    // Devise du compte

8. public string Titulaire

9. {

10. get { return titulaire; }

11. }

13. public double Solde

14. {

15. get { return solde; }

16. }

18. public string Devise

19. {

20. get { return devise; }

21. }

23. // Constructeur

24. public CompteBancaire(string leTitulaire, double soldeInitial, string laDevise)

25. {

26. titulaire = leTitulaire;

27. solde = soldeInitial;

28. devise = laDevise;

29. }

31. // Ajoute un montant au compte

32. public void Crediter(double montant)

33. {

34. solde = solde + montant;

35. }

37. // Retire un montant au compte

38. public void Debiter(double montant)

39. {

40. solde = solde - montant;

41. }

43. // Renvoie la description du compte

44. public string Decrire()

45. {

46. string description = "Le solde du compte de " + titulaire + " est de " + solde + " " + devise;

47. return description;

48. }

49. }

Le programme de test associé est le suivant.

CompteBancaire compte = new CompteBancaire("Pierre", 100, "dollars");
Console.WriteLine(compte.Decrire());

VII-B - Autoréférence : le mot-clé this

DÉFINITION : à l'intérieur d'une méthode, le mot-clé this permet d'accéder à l'instance (l'objet) sur lequel la méthode est appelée.

L'une des utilisations fréquentes de this consiste à lever les ambiguïtés de nommage entre attributs et paramètres. Par exemple, on pourrait réécrire le constructeur de CompteBancaire en changeant les noms de ses paramètres de la manière suivante.

1. public CompteBancaire(string titulaire, double soldeInitial, string devise)

2. {

3. this.titulaire = titulaire;

4. solde = soldeInitial;

5. this.devise = devise;

6. }

Ici, l'expression this.titulaire désigne sans ambiguïté l'attribut de la classe, alors que l'expression titulaire désigne le paramètre du constructeur. Il n'y a pas non plus d'ambiguïté entre l'attribut solde et le paramètre soldeInitial qui porte un nom différent.

VII-C - Utilisation des méthodes de base

Nous allons voir que toute classe C# dispose d'un certain nombre de méthodes de base que nous pouvons exploiter.

VII-C-1 - Découverte de la classe Object

Utilisons une fonctionnalité de Visual Studio pour découvrir si des méthodes sont redéfinissables dans CompteBancaire. En tapant le mot-clé override dans une classe, l'IDE nous propose la liste des méthodes de cette classe qu'il est possible de redéfinir. Étant donné que CompteBancaire n'hérite a priori d'aucune classe, on s'attend à ce que cette liste soit vide.

L'IDE nous propose pourtant trois méthodes à redéfinir : Equals, GetHashCode et ToString. Ceci s'explique par le fait que toute classe C# qui n'hérite explicitement d'aucune autre, hérite implicitement d'une classe de base nommée Object. Ce design est inspiré de celui du langage Java.

La documentation Microsoft donne plus de précisions sur cette classe : « Il s'agit de la classe de base fondamentale parmi toutes les classes du .NET Framework. Elle constitue la racine de la hiérarchie des types. »

La classe Object dispose de plusieurs méthodes. Toute classe C# hérite directement ou indirectement de cette classe et peut utiliser ou redéfinir ses méthodes.

VII-C-2 - Redéfinition de ToString

Parmi les méthodes présentes dans la classe Object, la plus souvent redéfinie dans les classes dérivées est ToString.

DÉFINITION : la méthode ToString permet de décrire un objet sous la forme d'une chaîne de caractères.

REMARQUE : examinons tout d'abord le comportement par défaut (sans redéfinition) de cette méthode.

CompteBancaire compte = new CompteBancaire("Pierre", 100, "dollars");
Console.WriteLine(compte.ToString());

Par défaut, la méthode ToString affiche le nom complet de la classe. Ici, Chap6_Complements désigne l'espace de noms (namespace) dans lequel la classe CompteBancaire est définie.

1. namespace Chap6_Complements

2. {

3. public class CompteBancaire

4. {

5. // ...

À présent, nous allons redéfinir la méthode ToString pour qu'elle renvoie des informations plus utiles sur le compte.

1. public class CompteBancaire

2. {

3. // ...

5. public override string ToString()

6. {

7. return "Le solde du compte de " + titulaire + " est de " + solde + " " + devise;

8. }

9. }

La méthode ToString est utilisée implicitement lorsqu'un objet est affiché dans la console ou dans un contrôle graphique WinForms (liste déroulante, etc.). On peut donc simplifier le programme principal en passant simplement l'objet à afficher à Console.WriteLine.

CompteBancaire compte = new CompteBancaire("Pierre", 100, "dollars");
Console.WriteLine(compte);

Le résultat est identique au précédent.

REMARQUE : la méthode Decrire, qui fait doublon avec ToString, peut maintenant être supprimée.

VII-D - Surcharge de méthodes

VII-D-1 - Définition de plusieurs constructeurs

La classe CompteBancaire possède un constructeur qui initialise les attributs du compte créé.

1. public CompteBancaire(string titulaire, double soldeInitial, string devise)

2. {

3. this.titulaire = titulaire;

4. solde = soldeInitial;

5. this.devise = devise;

6. }

Ce constructeur nous oblige à définir systématiquement le titulaire, le solde et la devise de tout nouveau compte. Or, les nouveaux comptes bancaires disposent le plus souvent d'un solde égal à zéro et utilisent l'euro comme devise. Pour refléter cet aspect du domaine et faciliter l'utilisation de notre classe, on va ajouter un autre constructeur. Il prendra uniquement le titulaire en paramètre, et initialisera solde et devise avec leurs valeurs par défaut.

1. public CompteBancaire(string titulaire)

2. {

3. this.titulaire = titulaire;

4. solde = 0;

5. devise = "euros";

6. }

On peut à présent instancier un compte bancaire en utilisant ce constructeur. On remarque que Visual Studio nous propose deux choix d'autocomplétion, visualisables à l'aide de petites flèches noires.

Cela signifie qu'il existe à présent deux constructeurs, donc deux manières d'instancier un compte. On choisit d'utiliser le nouveau constructeur.

CompteBancaire compte = new CompteBancaire("Paul");
Console.WriteLine(compte);

On constate que tous les attributs du compte ont été correctement initialisés.

VII-D-2 - Chaînage de constructeurs

Revenons à notre nouveau constructeur. Il est possible d'améliorer sa définition de la manière suivante.

1. public CompteBancaire(string titulaire) : this(titulaire, 0, "euros")

2. {

3. // Rien à faire !

4. }

Cette variante n'initialise pas directement les attributs, mais fait appel à l'autre constructeur en lui passant en paramètres des valeurs par défaut pour les attributs que lui-même n'a pas reçus en paramètres.

CONSEIL : le chaînage des constructeurs les uns aux autres est une bonne pratique pour éviter la duplication du code d'initialisation des attributs.

Imaginons que l'on souhaite pouvoir instancier un compte bancaire en définissant son titulaire et son solde (la devise par défaut étant toujours l'euro). On va ajouter à notre classe un troisième constructeur en les chaînant les uns aux autres. Voici les trois constructeurs de CompteBancaire.

1. public CompteBancaire(string titulaire) : this(titulaire, 0)

2. {}

4. public CompteBancaire(string titulaire, double soldeInitial)

5. : this(titulaire, soldeInitial, "euros")

6. {}

8. public CompteBancaire(string titulaire, double soldeInitial, string devise)

9. {

10. this.titulaire = titulaire;

11. solde = soldeInitial;

12. this.devise = devise;

13. }

En diversifiant la manière dont un objet peut être instancié, la présence de plusieurs constructeurs facilite l'utilisation d'une classe,

VII-D-3 - Surcharge d'une méthode

Le mécanisme qui consiste à définir plusieurs constructeurs avec des signatures différentes s'appelle la surcharge.

DÉFINITION : la signature d'une méthode comprend son nom et la liste de ses paramètres (nombre, types et ordre).

On peut également appliquer ce mécanisme pour définir plusieurs méthodes avec des signatures différentes. Par exemple, on pourrait vouloir créditer (ou débiter) un compte avec un montant exprimé dans une devise différente. La solution logique est de créer une nouvelle méthode Crediter (ou Debiter) prenant en paramètres montant et devise du montant.

1. // ...

3. public void Crediter(double montant)

4. {

5. solde += montant;

6. }

8. public void Crediter(double montant, string devise)

9. {

10. if (devise == Devise)

11. Crediter(montant);

12. else

13. {

14. // TODO : gérer la conversion de devises

15. }

16. }

18. // ...

Les deux méthodes portent le même nom, mais leurs signatures (nom + paramètres) sont différentes, d'où l'absence d'erreur de compilation. Le comportement de l'IDE lors de l'utilisation de la nouvelle méthode reflète la présence de deux signatures pour la méthode Crediter.

DÉFINITION : la surcharge est un mécanisme qui consiste à définir plusieurs variantes d'une même méthode avec des signatures différentes.

Le mécanisme de surcharge permet de proposer plusieurs variantes d'un même comportement. On le trouve fréquemment à l'œuvre dans les classes du framework .NET. À titre d'exemple, la méthode Console.WriteLine totalise ci-dessous 19 signatures différentes.

ATTENTION : ne pas confondre surcharge et redéfinition.

- Surcharger (to overload) une méthode, c'est ajouter une méthode de même nom avec une signature différente.

- Redéfinir (to override) une méthode, c'est la réécrire (avec la même signature) dans une classe dérivée.

VII-D-4 - Surcharge et valeur de retour

On pourrait envisager de surcharger la méthode Crediter afin de renvoyer le solde du compte après l'opération.

1. // ...

3. public void Crediter(double montant)

4. {

5. solde += montant;

6. }

8. public double Crediter(double montant)

9. {

10. solde += montant;

11. return solde;

12. }

14. // ...

La définition de cette méthode provoque une erreur de compilation.

Cette erreur s'explique par le fait que la signature d'une méthode (nom + paramètres) n'inclut pas son type de retour.

ATTENTION : il est impossible de surcharger une méthode en modifiant uniquement son type de retour.

VII-E - Membres de classe

VII-E-1 - Evolution des besoins

Imaginons que l'on souhaite identifier les comptes bancaires par un numéro unique. Le premier compte créé porterait le numéro 1, le second le numéro 2, etc.

Une possibilité serait de passer le numéro de compte en paramètre au constructeur. Cependant, elle serait fastidieuse à utiliser (l'utilisateur de la classe doit gérer lui-même l'unicité et l'incrémentation des numéros) et source d'erreurs (aucun contrôle et risque de doublons dans les numéros).

Une meilleure solution consiste à internaliser la problématique de définition du numéro à l'intérieur de la classe CompteBancaire. Il faudrait stocker dans la classe l'information sur le numéro du prochain compte. Lors de la création du compte suivant (c'est-à-dire lors du prochain appel au constructeur de la classe CompteBancaire), le numéro du prochain compte lui serait attribué, puis incrémenté.

Essayons de mettre en œuvre cette stratégie.

1. public class CompteBancaire

2. {

3. private string titulaire;

4. private double solde;

5. private string devise;

6. private int numero;  // numéro du compte

7. private int numeroProchainCompte = 1;  // numéro du prochain compte créé

9. public CompteBancaire(string titulaire, double soldeInitial, string devise)

10. {

11. // ... (initialisation des attributs titulaire, solde et devise)

13. numero = numeroProchainCompte;

14. numeroProchainCompte++;

15. }

17. // ...

19. public override string ToString()

20. {

21. return "Le solde du compte numéro " + numero + " de " + titulaire + " est de " + solde + " " + devise;

22. }

23. }

Cependant, on constate un problème lorsque l'on instancie plusieurs objets.

1. CompteBancaire comptePierre = new CompteBancaire("Pierre");

2. CompteBancaire comptePaul = new CompteBancaire("Paul");

3. CompteBancaire compteJacques = new CompteBancaire("Jacques");

5. Console.WriteLine(comptePierre);

6. Console.WriteLine(comptePaul);

7. Console.WriteLine(compteJacques);

Tous les comptes portent le même numéro ! Cela vient du fait que chaque instance de la classe CompteBancaire dispose de ses propres attributs. Chaque objet a donc un attribut numeroProchainCompte qui lui est propre, avec une valeur spécifique. L'incrémentation de cet attribut dans le constructeur, lors de l'instanciation d'un compte, n'a aucun effet sur les attributs des instances existantes. Voici les valeurs de ces attributs une fois les objets créés.

VII-E-2 - Définition d'un attribut de classe

La solution serait de lier l'attribut numeroProchainCompte à la classe CompteBancaire elle-même, et non à chacune de ses instances. Le même attribut serait partagé entre toutes les instances de la classe, ce qui permettrait d'obtenir des numéros de compte uniques.

On parle d'attributs de classe, par opposition aux attributs appelés attributs d'instance quand on veut distinguer les deux types d'attributs.

En C# (ainsi qu'en Java et en C++), la création d'un attribut de classe se fait en précisant sa définition grâce au mot-clé static, que vous avez déjà rencontré par ailleurs.

1. public class CompteBancaire

2. {

3. // ...

4. private int numero;

5. private static int numeroProchainCompte = 1;  // Numéro du prochain compte créé

7. // ...

Le même programme principal donne à présent un résultat différent.

Ce résultat illustre le fait que l'attribut numeroProchainCompte est maintenant lié à la classe et partagé entre toutes ses instances. À chaque appel du constructeur, sa valeur courante est récupérée puis incrémentée. On obtient le comportement désiré.

VII-E-3 - Définition d'une méthode de classe

Ce qui vient d'être vu pour les attributs s'applique également aux méthodes. En utilisant le mot-clé static, on définit une méthode de classe, par opposition aux méthodes habituelles appelées méthodes d'instance pour les distinguer.

Imaginons que l'on souhaite pouvoir récupérer le numéro du prochain compte. Il faut ajouter une méthode à la classe CompteBancaire.

1. // ...

3. public static int GetNumeroProchainCompte()

4. {

5. return numeroProchainCompte;

6. }

8. // ...

On peut modifier le programme principal afin d'exploiter cette nouvelle méthode.

1. Console.WriteLine("Avant la 1re création : " + CompteBancaire.GetNumeroProchainCompte());

2. CompteBancaire comptePierre = new CompteBancaire("Pierre");

4. Console.WriteLine("Avant la 2e création : " + CompteBancaire.GetNumeroProchainCompte());

5. CompteBancaire comptePaul = new CompteBancaire("Paul");

7. Console.WriteLine("Avant la 3e création : " + CompteBancaire.GetNumeroProchainCompte());

8. CompteBancaire compteJacques = new CompteBancaire("Jacques");

10. Console.WriteLine("Après la 3e création : " + CompteBancaire.GetNumeroProchainCompte());

12. Console.WriteLine(comptePierre);

13. Console.WriteLine(comptePaul);

14. Console.WriteLine(compteJacques);

Une méthode de classe s'utilise de manière différente d'une méthode d'instance. Elle peut s'utiliser en l'absence de toute instance de la classe. Étant donné qu'elle est liée à la classe et non à ses instances, on préfixe le nom de la méthode par le nom de la classe, par exemple CompteBancaire.GetNumeroProchainCompte().

Une méthode de classe sert à définir un comportement indépendant de toute instance. Vous en avez déjà rencontrées certaines en utilisant les classes du framework .NET, par exemple Console.WriteLine ou Convert.ToDouble.

REMARQUE : la méthode Main, point d'entrée dans une application console en C#, est en réalité une méthode de classe de la classe Program.

Il existe des contraintes, somme toute logiques, concernant les interactions entre membres de classe et membres d'instance.

- Une méthode d'instance peut utiliser un membre de classe (attribut ou méthode).

- Une méthode de classe peut utiliser un membre de classe.

- Une méthode de classe ne peut pas utiliser un membre d'instance (attribut ou méthode).

- Une méthode de classe ne peut pas utiliser le mot-clé this.

VIII - Gestion des exceptions

L'objectif de ce chapitre est de se familiariser avec le mécanisme de gestion des exceptions.

Les exemples de code associés sont disponibles en ligne.

VIII-A - Introduction : qu'est-ce qu'une exception ?

Commençons ce chapitre par une définition.

DÉFINITION : une exception est un évènement qui apparaît pendant le déroulement d'un programme et qui empêche la poursuite normale de son exécution.

Autrement dit, une exception représente un problème qui survient dans un certain contexte : base de données inaccessible, mauvaise saisie utilisateur, fichier non trouvé… Comme son nom l'indique, une exception traduit un évènement exceptionnel et a priori imprévisible. Pour essayer de répondre à ce problème, le programmeur doit mettre en place un mécanisme de gestion des exceptions.

La suite de ce chapitre utilise le langage C#, mais presque tous les concepts étudiés se retrouvent dans d'autres langages comme Java.

VIII-B - Fonctionnement général des exceptions

VIII-B-1 - Contexte choisi

Pour illustrer le fonctionnement des exceptions en C#, nous allons prendre exemple sur le plus célèbre des gaffeurs (© Marsu Productions).

Nous allons modéliser Gaston sous la forme d'une classe dont les méthodes refléteront le comportement.

REMARQUE : afin de vous permettre d'identifier le moment où se produisent des exceptions, les méthodes de cet exemple affichent les messages sur la sortie standard (Console.WriteLine). En règle générale, cette pratique est déconseillée.

VIII-B-2 - Un premier exemple

Pour commencer, ajoutons à Gaston la capacité (limitée…) de trier le courrier en retard.

1. public class GastonLagaffe

2. {

3. public void TrierCourrierEnRetard(int nbLettres)

4. {

5. Console.Write("Quoi, " + nbLettres + " lettre(s) à trier ? ");

6. try

7. {

8. Console.WriteLine("OK, OK, je vais m'y mettre...");

9. if (nbLettres > 2)

10. {

11. throw new Exception("Beaucoup trop de lettres...");

12. }

13. Console.WriteLine("Ouf, j'ai fini.");

14. }

15. catch (Exception e)

16. {

17. Console.WriteLine("M'enfin ! " + e.Message);

18. }

19. Console.WriteLine("Après tout ce travail, une sieste s'impose.");

20. }

21. }

Le programme principal demande à Gaston de trier deux lettres, exécutons-le.

1. GastonLagaffe gaston = new GastonLagaffe();

2. Console.WriteLine("Debout Gaston ! Il faut trier le courrier !");

3. gaston.TrierCourrierEnRetard(2);

Modifions notre programme principal afin de demander à Gaston de trier trois lettres.

// ...
gaston.TrierCourrierEnRetard(3);

Nous obtenons cette fois-ci un résultat bien différent.

VIII-B-3 - Analyse de l'exemple

La capacité de travail de Gaston étant limitée, celui-ci est incapable de trier plus de deux lettres en retard. Comme le résultat de l'exécution nous le montre, le début de l'appel de la méthode TrierCourrierEnRetard avec trois lettres s'est déroulé normalement.

1. Console.Write("Quoi, " + nbLettres + " lettre(s) à trier ? ");

2. try

3. {

4. Console.WriteLine("OK, OK, je vais m'y mettre...");

5. // ...

Ensuite, le test ci-dessous a déclenché ce que l'on appelle la levée d'une exception.

1. // ...

2. if (nbLettres > 2)

3. {

4. throw new Exception("Beaucoup trop de lettres...");

5. }

6. // ...

On remarque que cette levée d'exception a interrompu le déroulement du reste de la méthode (absence de l'affichage « Ouf, j'ai fini »). En revanche, nous obtenons le message  ! « M'enfin ! Beaucoup trop de lettres », qui provient du bloc de code catch.

1. // ...

2. catch (Exception e)

3. {

4. Console.WriteLine("M'enfin ! " + e.Message);

5. }

6. // ...

Cela signifie que l'exception levée a été interceptée dans ce bloc de code, déclenchant l'affichage du message d'erreur. Pour terminer, on observe que la dernière instruction de la méthode a malgré tout été exécutée.

1. // ...

2. Console.WriteLine("Après tout ce travail, une sieste s'impose.");

3. }

VIII-B-4 - Conclusion provisoire

Nous avons découvert de nouveaux mots-clés qui permettent la gestion des exceptions en C# :

- try délimite un bloc de code dans lequel des exceptions peuvent se produire ;

- catch délimite un bloc de code qui intercepte et gère les exceptions levées dans le bloc try associé ;

- throw lève une nouvelle exception.

La syntaxe générale de la gestion des exceptions est la suivante.

1. try

2. {

3. // code susceptible de lever des exceptions

4. }

5. catch (Exception e)

6. {

7. // code de gestion de l'exception qui s'est produite dans le bloc try

8. }

Il s'agit d'une première approche très simpliste. Il nous reste de nombreux points à examiner.

VIII-C - Détail du fonctionnement des exceptions

VIII-C-1 - Une exception est un objet

Intéressons-nous à la partie du code précédent qui lève une exception.

1. // ...

2. throw new Exception("Beaucoup trop de lettres...");

3. // ...

On voit que l'exception est instanciée comme un objet classique grâce au mot-clé new, puis levée (ou encore « jetée ») grâce au mot-clé throw. On aurait pu décomposer le code ci-dessus de la manière suivante (rarement utilisée en pratique).

1. // ...

2. Exception exception = new Exception("Beaucoup trop de lettres...");

3. throw exception;

4. // ...

Analysons le contenu du bloc catch.

1. // ...

2. catch (Exception e)

3. {

4. Console.WriteLine("M'enfin ! " + e.Message);

5. }

6. // ...

On observe que la variable e utilisée est en réalité un objet, instance de la classe Exception. On constate que cette classe dispose (entre autres) d'une propriété C# nommée Message, qui renvoie le message véhiculé par l'exception.

La classe Exception propose également des méthodes comme ToString, qui renvoient des informations plus détaillées sur l'erreur.

VIII-C-2 - Une exception remonte la chaîne des appels

Dans l'exemple précédent, nous avons intercepté dans le programme principal une exception levée depuis une méthode. Découvrons ce qui se produit lors d'un appel de méthodes en cascade.

Pour cela, on ajoute plusieurs méthodes à la classe GastonLagaffe.

1. public class GastonLagaffe

2. {

3. // ...

4. public void FaireSignerContrats()

5. {

6. try

7. {

8. Console.WriteLine("Encore ces contrats ? OK, je les imprime...");

9. ImprimerContrats();

10. Console.WriteLine("À présent une petite signature...");

11. AjouterSignature();

12. Console.WriteLine("Fantasio, les contrats sont signés !");

13. }

14. catch (Exception e)

15. {

16. Console.WriteLine("M'enfin ! " + e.Message);

17. }

18. }

20. public void AjouterSignature()

21. {

22. Console.WriteLine("Signez ici, M'sieur Demesmaeker.");

23. }

25. public void ImprimerContrats()

26. {

27. Console.WriteLine("D'abord, mettre en route l'imprimante.");

28. AllumerImprimante();

29. Console.WriteLine("Voilà, c'est fait !");

30. }

32. public void AllumerImprimante()

33. {

34. Console.WriteLine("Voyons comment allumer cette machine...");

35. throw new Exception("Mais qui a démonté tout l'intérieur ?");

36. }

37. }

On modifie également le programme principal pour demander à Gaston de faire signer les contrats.

1. GastonLagaffe gaston = new GastonLagaffe();

2. Console.WriteLine("Gaston, Mr, Demesmaeker arrive, faites vite !");

3. gaston.FaireSignerContrats();

Le résultat de l'exécution est donné ci-après.

On constate que l'exception levée dans la méthode AllumerImprimante a été propagée par la méthode ImprimerContrats, puis finalement interceptée dans le bloc catch de la méthode FaireSignerContrats.

Une exception levée remonte la chaîne des appels dans l'ordre inverse, jusqu'à être interceptée dans un bloc catch. Dans le cas où aucun gestionnaire d'exception n'est trouvé, l'exécution du programme s'arrête avec un message d'erreur. Pour le constater, on modifie le programme principal pour faire appel à une méthode qui lève une exception.

// ...
gaston.AllumerImprimante();

On obtient cette fois-ci une erreur à l'exécution.

DANGER ! Une exception non interceptée provoque un arrêt brutal de l'exécution d'un programme.

VIII-C-3 - Les exceptions forment une hiérarchie

Jusqu'à présent, nous avons utilisé uniquement la classe C# standard Exception pour véhiculer nos exceptions. Il est possible de dériver cette classe afin de créer nos propres classes d'exceptions. Voici le diagramme de classes UML associé.

La valeur ajoutée de ces classes se limite à la modification du message d'erreur de l'exception.

1. public class ExceptionMenfin : Exception

2. {

3. public ExceptionMenfin(string message) : base("M'enfin ! " + message)

4. { }

5. }

1. public class ExceptionBof : Exception

2. {

3. public ExceptionBof(string message) : base("Bof ! " + message)

4. { }

5. }

On ajoute à notre classe GastonLagaffe la capacité de répondre (ou pas…) au téléphone.

1. public class GastonLagaffe

2. {

3. // ...

5. public void RepondreAuTelephone(string appelant)

6. {

7. if (appelant == "Mr. Boulier")

8. {

9. throw new ExceptionMenfin("Je finis un puzzle.");

10. }

11. else if (appelant == "Prunelle")

12. {

13. throw new ExceptionBof("Pas le temps, je suis dé-bor-dé !");

14. }

15. else

16. {

17. Console.WriteLine("Allô, ici Gaston, j'écoute...");

18. }

19. }

20. }

On ajoute au programme principal un sous-programme qui appelle cette méthode et intercepte les éventuelles exceptions.

1. // ...

2. private static void GererAppel(GastonLagaffe gaston, string appelant)

3. {

4. Console.WriteLine("Gaston, " + appelant + " au téléphone !");

5. try

6. {

7. gaston.RepondreAuTelephone(appelant);

8. }

9. catch (ExceptionMenfin e)

10. {

11. Console.WriteLine("Pas de réponse... Et pourquoi ?");

12. Console.WriteLine(e.Message);

13. }

14. catch (ExceptionBof e)

15. {

16. Console.WriteLine("Ca sonne toujours... vous dormez ou quoi ?");

17. Console.WriteLine(e.Message);

18. }

19. Console.WriteLine();

20. }

Enfin, le programme principal appelle ce sous-programme plusieurs fois.

1. GastonLagaffe gaston = new GastonLagaffe();

3. GererAppel(gaston, "Mr. Boulier");

4. GererAppel(gaston, "Prunelle");

5. GererAppel(gaston, "Jules-de-chez-Smith");

Le résultat de l'exécution est présenté ci-dessous.

On constate que l'exception de type ExceptionMenfin a été interceptée dans le premier bloc catch, et l'exception de type ExceptionBof a été interceptée dans le second.

Comme nos exceptions héritent toutes deux de la classe Exception, il est possible de simplifier l'appel en interceptant uniquement Exception.

1. // ...

2. private static void GererAppel(GastonLagaffe gaston, string appelant)

3. {

4. Console.WriteLine("Gaston, " + appelant + " au téléphone !");

5. try

6. {

7. gaston.RepondreAuTelephone(appelant);

8. }

9. catch (Exception e)  // intercepte toute exception

10. {

11. Console.WriteLine("Encore une bonne excuse, j'imagine ?");

12. Console.WriteLine(e.Message);

13. }

14. Console.WriteLine();

15. }

Le résultat de l'exécution est maintenant le suivant.

Afin de limiter le nombre de blocs catch, on peut donc intercepter une superclasse commune à plusieurs exceptions. C'est particulièrement intéressant dans le cas où le traitement de l'erreur est le même dans tous les catch. En revanche, intercepter une superclasse plutôt que les classes dérivées fait perdre l'information sur le type exact de l'exception. Dans l'exemple précédent, on ne sait plus si l'exception interceptée est une ExceptionMenfin ou une ExceptionBof. Quand le traitement doit être différencié selon le type de l'erreur rencontrée, il faut donc ajouter autant de blocs catch que de types d'exception.

VIII-D - Avantages apportés par les exceptions

Il existe d'autres solutions que l'utilisation des exceptions pour gérer les erreurs qui peuvent se produire durant l'exécution d'un programme. Cependant, les exceptions apportent de nombreux avantages. Le principal est qu'elles permettent de regrouper et de différencier le code de gestion des erreurs du code applicatif.

Sans utiliser d'exceptions, on est obligé de tester la réussite de chaque opération au fur et à mesure du déroulement. Par exemple, une méthode d'écriture dans un fichier pourrait s'écrire comme ci-dessous.

1. public void EcrireDansFichier(string texte)

2. {

3. <ouverture du fichier en écriture>

4. if <ouverture en écriture ok>

5. {

6. <écriture du texte dans le fichier>

7. if <écriture du texte ok>

8. {

9. <fermeture du fichier>

10. if <fermeture ok>

11. {

12. <affichage message : succès>

13. }

14. else

15. {

16. <affichage message : erreur de fermeture>

17. }

18. }

19. else

20. {

21. <affichage message : erreur d'écriture>

22. }

23. }

24. else

25. {

26. <affichage message : impossible d'ouvrir le fichier>

27. }

28. }

Réécrit en utilisant des exceptions, ce code pourrait devenir :

1. public void EcrireDansFichier(string texte)

2. {

3. try

4. {

5. <ouverture du fichier en écriture>

6. <écriture du texte dans le fichier>

7. <fermeture du fichier>

8. <affichage message : succès>

9. }

10. catch (<exception de type ouverture impossible>)

11. {

12. <affichage message : impossible d'ouvrir le fichier>

13. }

14. catch (<exception de type écriture impossible>)

15. {

16. <affichage message : erreur d'écriture>

17. }

18. catch (<exception de type fermeture impossible>)

19. {

20. <affichage message : erreur de fermeture>

21. }

22. }

Cette solution sépare le code applicatif du code de gestion des erreurs, regroupé dans les catch. Si la gestion des erreurs est la même dans tous les blocs catch, on peut même simplifier encore le code précédent.

1. public void EcrireDansFichier(string texte)

2. {

3. try

4. {

5. <ouverture du fichier en écriture>

6. <écriture du texte dans le fichier>

7. <fermeture du fichier>

8. <affichage message : succès>

9. }

10. catch (<exception la plus générale>)

11. {

12. <affichage message véhiculé par l'exception>

13. }

14. }

VIII-E - Bonnes pratiques dans la gestion des exceptions

Comme nous l'avons vu, les exceptions constituent une nette amélioration pour gérer les erreurs, à condition de savoir les employer correctement.

VIII-E-1 - Savoir quand lever une exception

La première règle, et probablement la plus importante, est que l'usage des exceptions doit rester réservé aux cas exceptionnels (comme leur nom l'indique).

IMPORTANT : une méthode ne lève une exception qu'en dernier recours pour signaler qu'elle est incapable de continuer à s'exécuter normalement.

Par exemple, on pourrait avoir la mauvaise idée d'employer les exceptions comme ci-dessous, pour sortir d'une boucle.

1. i = 0;

2. trouve = false;

3. while (!trouve)

4. {

5. i++;

6. if (i == 10)

7. throw new Exception("Fin de la boucle");

8. else

9. // ...

10. }

Écrire ce genre de code est très maladroit pour plusieurs raisons :

- il ne correspond pas à la philosophie des exceptions, à savoir la gestion des erreurs ;

- il rend le code moins lisible et son exécution difficile à prévoir ;

- il est beaucoup plus lent : lever une exception est un processus coûteux en temps d'exécution.

VIII-E-2 - Savoir quand intercepter une exception

Une exception signale une erreur rencontrée durant l'exécution et remonte la chaîne des appels de méthode jusqu'à son interception (ou le « plantage » du programme). Il faut bien réfléchir avant de lancer une exception depuis une méthode et également avant de l'intercepter dans une autre méthode.

IMPORTANT : une méthode n'intercepte une exception que si elle est capable d'effectuer un traitement approprié (message d'erreur, nouvelle tentative, etc.).

Dans le cas contraire, il vaut mieux laisser l'exception se propager plus haut dans la chaîne des appels. Il ne sert strictement à rien d'intercepter une exception sans savoir quoi en faire, comme, par exemple dans le code ci-dessous.

1. try

2. {

3. // code susceptible de lever des exceptions

4. }

5. catch (Exception e)  // ce bloc catch est parfaitement inutile !

6. {

7. throw e;

8. }

Cet exemple intercepte les exceptions de la classe Exception (et de toutes les classes dérivées), mais se contente de relancer l'exception interceptée sans faire aucun traitement d'erreur. Il est plus lisible et plus efficace de supprimer le try … catch autour des instructions du bloc de code.

Autre mauvaise idée : écrire un bloc catch vide.

1. try

2. {

3. // code susceptible de lever des exceptions

4. }

5. catch (Exception e)

6. {

7. // l'exception est avalée par ce bloc catch !

8. }

Dans ce cas, l'exception est interceptée silencieusement, ou encore avalée, par ce bloc. Elle ne remonte plus la chaîne des appels et l'appelant n'aura aucun moyen de détecter l'erreur qui s'est produite. Plutôt que d'écrire un bloc catch vide, mieux vaut laisser l'exception se propager aux méthodes appelantes qui sauront mieux gérer l'erreur.

VIII-E-3 - Savoir quand créer ses propres classes d'exceptions

Dans un paragraphe précédent, nous avons créé deux nouvelles classes d'exceptions pour illustrer certains concepts : ExceptionMenfin et ExceptionBof. En pratique, un programmeur doit bien réfléchir avant de créer sa ou ses propre(s) classe(s) d'exceptions.

En règle générale, on ne crée de nouvelles classes d'exceptions que dans les cas suivants :

- on souhaite distinguer ses propres exceptions des exceptions standards ;

- on a besoin de véhiculer dans les exceptions des données spécifiques au programme.

Dans la plupart des situations courantes et pour des projets ne dépassant pas une certaine complexité, l'utilisation de la classe standard Exception suffit.

REMARQUE : dans le cas de la création d'une classe dérivée de Exception, il est fortement conseillé d'inclure le mot Exception dans le nom de la nouvelle classe.

IX - Note de la rédaction de Developpez.com

Nous tenons à remercier Winjerome pour la mise au gabarit et Jacques_Jean pour la relecture orthographique.