• Aucun résultat trouvé

[PDF] Débuter à la programmation événementielle avec le langage C# | Formation informatique

N/A
N/A
Protected

Academic year: 2021

Partager "[PDF] Débuter à la programmation événementielle avec le langage C# | Formation informatique"

Copied!
43
0
0

Texte intégral

(1)

2

C# : les classes

La classe est l’élément central dans un programme C#. Tout doit en effet être regroupé dans une classe. Nous avons d’ailleurs rencontré le mot class dans tous les programmes du chapitre 1, où il n’était pourtant jamais question de classe.

2.1 Notions de base

La classe comme type d’information

Une classe désigne un type plus complexe que les types primitifs (int, float, etc.) étudiés au chapitre 1. Une classe regroupe en effet :

• des variables, encore appelées champs ou attributs de la classe ;

• des fonctions, encore appelées méthodes, qui portent généralement sur ces champs (c-à-d qu’elles font intervenir ces champs) ;

• des propriétés et des indexeurs.

Une classe peut cependant comporter uniquement des champs ou uniquement des méthodes. Si elle ne comporte que des champs, elle ressemble plutôt à la structure du C mais avec d’importantes possibilités, notamment l’héritage, que nous développerons par la suite. En général, une classe contient à la fois des champs et des méthodes.

Une classe consiste en une description d’information à l’usage du compilateur, ce qui était déjà le cas de int ou de double. Mais la classe va beaucoup plus loin, ne serait-ce que parce que nous allons pouvoir définir nos propres classes.

(2)

Pour définir une classe (qui, ici, ne comprend que des champs), on écrit : class Pers { string Nom; int Age; }

Comme en C++, class est un mot réservé de C#. Pers est le nom que nous avons décidé de donner ici à la classe. La déclaration de la classe Pers indique quelles informations (dans notre cas le nom et l’âge) doivent être gardées sur une personne. Cette déclaration de classe est certes très simplifiée par rapport à toutes les possibilités qu’offre C# mais nous aurons bientôt l’occasion de la compléter.

Contrairement au C++, la déclaration de la classe ne doit pas se terminer par un point-virgule, bien que ce ne soit pas une erreur d’en placer un en C#.

Les objets

Différence par rapport au C++ : C# ne connaît que l’instanciation dynamique des objets. La classe consiste en une définition d’information, à l’usage du compilateur. La classe est donc du domaine de l’abstrait.

Un objet ne désigne rien d’autre qu’une variable d’une classe donnée. On parle aussi d’instance, à la place d’objet, et d’instanciation d’une classe donnée, pour l’opération de création d’un objet. En cours d’exécution de programme, à l’occasion de l’instanciation (par new), de la mémoire est réservée pour y loger l’objet. En fait, seuls les champs de la classe occupent de la place en mémoire. Le code des méthodes de la classe se trouve ailleurs en mémoire, précisément dans le segment de code du programme, tout à fait en dehors de l’objet. L’objet est donc de l’ordre du concret : il occupe de la mémoire au moment d’exécuter le programme. À une même classe (à une définition d’informations, si vous préférez) peut correspondre plusieurs objets (soit plusieurs variables). Au moment d’exécuter le programme, les différents objets occupent des zones de mémoire distinctes. À ce moment, la classe, elle, n’occupe aucune zone de mémoire : il s’agissait d’une information à l’usage du compilateur.

Pour créer un objet d’une classe donnée, il faut (c’est la seule technique possible) : • déclarer une référence à l’objet en question ;

• l’instancier par new, ce qui provoque une réservation d’espace mémoire. Par exemple :

Pers p; // référence p = new Pers(); // instanciation

p, qui désigne un objet de la classe Pers, est une référence. Il s’agit dans les faits (si l’on examine le code généré) d’un pointeur, mais vous ne pouvez pas le manipuler comme un pointeur du C/C++. Il faut que l’opérateur new soit exécuté pour que l’espace mémoire

(3)

destiné à l’objet soit alloué. À l’attention des programmeurs C++, signalons que les parenthèses sont requises, même si le constructeur est absent ou n’admet aucun argument.

En C#, les objets doivent donc être créés dynamiquement (sur le heap associé au programme et vous n’avez aucun pouvoir à ce niveau). Il est donc impossible, comme on peut le faire en C++ et comme on le fait d’ailleurs en C# pour les variables de types primitifs (y compris les structures), de déclarer simplement un objet et de le créer ainsi sur la pile : on parle dans ce cas d’allocation statique. C# ne connaît que l’allocation dynamique par new, pour les objets, et aussi pour les tableaux.

Pour créer deux objets de la classe Pers, on écrit :

Pers p1, p2; // deux objets de la classe Pers p1 = new Pers(); // instanciation du premier objet p2 = new Pers(); // instanciation du second objet

Les deux opérations peuvent être combinées en une seule. Il suffit pour cela d’écrire :

Pers p1=new Pers(), p2=new Pers();

Libération d’objet

Dans l’exemple précédent, p1 et p2 désignent deux objets de la classe Pers. Contrairement au C++, vous ne devez pas explicitement libérer les zones de mémoire associées à p1 et p2. L’opération delete n’existe tout simplement pas en C# : la libération de mémoire se fera automatiquement, dès que l’objet n’est plus accessible, généralement :

• parce qu’il a cessé d’exister, après sortie de la fonction ;

• parce qu’une nouvelle valeur, y compris null, a été affectée à la référence.

Notez-le, mais nous aurons l’occasion d’y revenir : « dès la sortie » ne veut pas dire « aussitôt après la sortie ». La zone de mémoire est marquée « à libérer » mais la libération (par le ramasse-miettes, appelé garbage collector en anglais) pourrait, sans conséquence pour votre programme, n’être effectuée que bien plus tard.

Accès aux champs d’un objet

Comme en C++, l’opérateur . (point) donne accès aux champs d’un objet. En C#, on part toujours d’une référence à l’objet, jamais d’un pointeur : l’opérateur est donc toujours le point. Ainsi, p1.Nom désigne le champ Nom de l’objet p1. Nous verrons plus loin, à la section 2.7, que des protections peuvent être placées sur des champs et des méthodes afin d’en limiter l’accès.

(4)

Pour avoir accès aux champs (par exemple Nom et Age) des objets p1 et p2 préalablement déclarés et instanciés, on écrit (les champs Nom et Age ont été qualifiés de public pour être rendus accessibles aux fonctions extérieures à la classe) :

class Pers {

public string Nom; // chaîne de caractères public int Age;

}

class Prog {

static void Main() {

Pers p1=new Pers(), p2=new Pers(); p1.Nom = "Jules"; p1.Age = 40; p2.Nom = "Jim"; p2.Age = 35; }

}

Dans Main, on a instancié ou créé, si vous préférez, deux objets. On a ensuite initialisé des caractéristiques (le nom et l’âge) pour chacun des objets.

Valeur initiale des champs

Une valeur d’initialisation peut être spécifiée pour un champ de la classe. Lors de l’instanciation d’un objet de cette classe, le champ en question de l’objet instancié aura automatiquement la valeur spécifiée dans sa déclaration :

class Pers {

public string Nom="Gaston"; public int Age=27;

}

Un champ initialisé pourra être modifié par la suite. Un champ non explicitement initialisé l’est automatiquement à zéro (0 dans le cas d’un champ de type entier, 0.0 dans le cas d’un champ de type réel, false dans le cas d’un champ de type booléen et chaîne vide dans le cas d’un objet string).

Champs

const

et

readonly

Un champ d’une classe peut être qualifié de const. Il doit alors être initialisé comme nous venons de le faire pour Nom et Age mais il ne peut être modifié par la suite.

Un champ d’une classe peut être qualifié de readonly. Il peut être initialisé dans le cons-tructeur de la classe mais il ne peut être modifié par la suite.

(5)

Les méthodes d’une classe

Avec les champs, nous sommes en territoire connu pour ceux qui connaissent les structures du C.

Une classe contient aussi généralement des méthodes, c-à-d des fonctions, qui portent sur les objets de la classe. Autrement dit : une méthode d’une classe « opère » sur un objet de cette classe, par exemple en manipulant des champs de cet objet ou en se servant de ces champs pour effectuer une opération. Si vous considérez une classe Compte représentant un compte en banque (il s’agit évidemment ici d’une simplification excessive) :

• le champ Identification reprend l’identification d’un compte et le champ Solde la somme en dépôt ;

• les fonctions associées au compte seront Déposer et Retirer ; • pour chaque client, il faudra créer un objet Compte.

Les méthodes d’une classe font partie de la classe en question, au même titre que les champs. L’ensemble « champs + méthodes » forme un tout et pourra être géré comme tel. Le fait de regrouper dans une même entité (la classe) des champs et des méthodes ayant un rapport entre eux porte le nom d’encapsulation.

Ainsi que nous le verrons plus loin, à la section 2.7, cet ensemble cohérent peut présenter une partie visible, dite « publique », et une partie invisible ou moins visible, dite « protégée » ou « privée », ce qui limite les risques de modifications inconsidérées de certains champs ainsi que les appels inconsidérés de certaines méthodes.

On pourrait par exemple créer une classe Fichier (ce qui est d’ailleurs inutile car une telle classe existe déjà, voir la section 23.1). Une telle classe contiendrait :

• des champs « visibles », c-à-d susceptibles d’être lus ou modifiés par des programmeurs utilisateurs de la classe (nom de fichier, taille, modes d’ouverture et d’accès, etc.) ; • des champs beaucoup moins accessibles ou totalement interdits d’accès, sauf au

concepteur de la classe comme le numéro interne de fichier, la position courante dans le fichier, etc. (par concepteur, il faut entendre celui qui dispose du fichier source de la classe) ;

• des méthodes d’accès pour l’ouverture du fichier, la lecture d’une fiche, etc. (méthodes susceptibles d’être appelées par des programmeurs utilisateurs de la classe) ;

• des méthodes interdites d’appel (par les programmeurs utilisateurs de la classe) mais nécessaires à l’implémentation des méthodes publiques (méthodes écrites par les programmeurs de la classe à l’usage exclusif des autres méthodes de la classe). Nous aurions ainsi regroupé dans une seule entité tout ce qui est nécessaire à la manipu-lation d’un fichier.

(6)

Un exemple d’utilisation de classe

Pour illustrer ce que nous avons vu jusqu’à présent concernant les classes, nous allons écrire un petit programme déclarant la classe Pers. Dans la fonction Main, on déclare et on instancie un objet de la classe Pers. On appelle ensuite la méthode Affiche appliquée à l’objet en question. La méthode Affiche « affiche » le nom de la personne suivi de l’âge de cette personne mis entre parenthèses.

En C#, le corps d’une méthode, qui regroupe les variables locales et les instructions de la fonction, plus savamment appelée méthode, doit se trouver dans la déclaration même de la classe. Vous ne trouverez donc pas en C# de fichier à inclure d’extension H, comme c’est le cas en C/C++. Aucun risque donc de désynchronisation entre les fichiers source et les fichiers à inclure.

Dans l’exemple précédent, peu importe la position de la classe Pers par rapport à la classe incluant Main (fonction dans laquelle on utilise Pers). Nul besoin non plus d’annoncer la classe si la ligne d’utilisation se trouve avant sa déclaration.

On a d’abord déclaré puis instancié (par new) un objet, appelé p, de la classe Pers. On a ensuite appelé la méthode Affiche appliquée à l’objet p. La méthode Affiche « opère » alors sur les champs de l’objet p. Il n’aurait pas été possible d’appeler la méthode Affiche sans désigner l’objet de la classe Pers, sur lequel porte la méthode : une méthode d’une classe (sauf les méthodes statiques, voir plus loin dans ce chapitre) travaille impérativement sur un objet de cette classe.

Appel d’une méthode d’une classe Ex1.cs

using System;

class Pers {

public string Nom; // deux champs

public int Age;

public void Affiche() // une méthode

{ Console.WriteLine(Nom + " (" + Age + ")"); } } class Prog {

static void Main() {

Pers p = new Pers(); // objet de la classe Pers p.Nom = "Jules"; // modification des champs

p.Age = 50;

p.Affiche(); // affichage : Jules (50) }

(7)

Accès aux champs et méthodes

À partir d’une méthode de la classe (par exemple Affiche de la classe Pers), on a accès sans restriction aux champs de l’objet sur lequel la méthode travaille. Il suffit pour cela de mentionner le nom du champ (par exemple Nom). Il en va de même pour les méthodes : une méthode f de la classe peut appeler une autre méthode (appelons-la g) de la même classe Pers. Lors de cet appel, aucun nom d’objet ne doit être spécifié car g travaille sur le même objet que f.

Pour résumer, supposons que p1 et p2 soient deux objets de la classe Pers. Lors de l’appel de la méthode par p1.Affiche(), on affiche le nom et l’âge de p1 (Affiche travaille alors sur p1). Lors de l’appel par p2.Affiche(), on affiche le nom et l’âge de p2 (Affiche travaille alors sur p2).

Dans une classe, plusieurs méthodes peuvent porter le même nom. Elles doivent alors se distinguer soit par le nombre, soit par le type des arguments. Elles ne peuvent être distinctes par leur seule valeur de retour.

Champs et méthodes de même nom dans des classes différentes

Rien n’empêche que des classes différentes contiennent des champs et des méthodes qui portent le même nom. Partons de l’exemple précédent et ajoutons la classe Animal :

class Animal {

public string Espèce; // deux champs public int Poids;

void Affiche() // une méthode {

Console.WriteLine(Espèce + " pesant " + Poids + " kilos"); }

}

Dans la fonction Main de l’exemple précédent, on écrit :

Pers p = new Pers(); p.Nom = "Goudurix"; p.Age = 18; Animal a = new Animal(); a.Espace = "Chien"; a.Poids = 10; p.Affiche(); // affiche Goudurix (18)

a.Affiche(); // affiche Chien pesant 10 kilos

On retrouve la méthode Affiche à la fois dans la classe Pers et la classe Animal. La « même » méthode, en apparence seulement, peut être appliquée à l’objet p (un objet de la classe Pers) ou à l’objet a (un objet de la classe Animal). La méthode Affiche peut pren-dre l’une ou l’autre forme selon qu’elle porte sur un objet Pers ou un objet Animal. Banal, direz-vous. De manière érudite, nous dirons que nous avons fait du polymorphisme.

(8)

Les surcharges de méthodes

Plusieurs méthodes sont généralement déclarées dans une classe et certaines d’entre elles peuvent porter le même nom. Elles doivent alors se distinguer par le nombre ou le type de leurs arguments. On parle alors de « surcharge » de méthode (overload en anglais). Deux méthodes d’une même classe ne peuvent cependant pas porter le même nom, accepter les mêmes arguments et être seulement distinctes par le type de la valeur de retour. Dans l’exemple suivant, on trouve trois méthodes qui s’appellent Modifier :

class Pers {

string Nom="moi"; // deux champs int Age=-1;

// trois méthodes qui s’appellent Modifier public void Modifier(string N) {Nom = N;} public void Modifier(int A) {Age = A;}

public void Modifier(string N, int A) {Nom = N; Age = A;} }

Notez que les champs Nom et Age ne sont plus publics, ils sont maintenant privés (par défaut, les champs non qualifiés sont privés). À partir de Main, où l’on crée les objets, nous n’avons plus accès à ces champs. À partir des méthodes de la classe Pers, l’accès aux champs Nom et Age ne pose cependant aucun problème. Dire qu’un champ d’une classe est privé signifie que l’accès à ce champ n’est possible qu’à partir des méthodes de sa classe. Dans la fonction Main de l’autre classe, c-à-d celle qui englobe la fonction Main et que nous avons systématiquement appelé Prog, on modifierait soit le nom, soit l’âge, soit encore le nom et l’âge d’une personne en écrivant :

Pers p = new Pers(); // objet de la classe Pers p.Modifier("Henri"); // appel de la première forme p.Modifier("Hector", 25); // appel de la troisième forme Écrire à partir de Main

p.Nom = "Isidore";

aurait constitué une erreur de syntaxe, le champ Nom n’étant pas directement accessible en dehors de méthodes de la classe Pers.

Le mot réservé

this

Il est possible de donner à un argument de méthode le même nom qu’à un champ de la classe. Dans ce cas, il faut écrire la méthode de la manière suivante :

void Modifier(string Nom, int Age) {

this.Nom = Nom; this.Age = Age; }

(9)

this, qui est un mot réservé du C#, désigne l’objet sur lequel opère la méthode. this est utilisable dans toutes les fonctions membres non statiques de la classe. À l’intérieur de la méthode Modifier (où il y a ambiguïté entre le nom d’un argument et le nom d’un champ de la classe), Nom désigne l’argument tandis que this.Nom désigne le champ Nom de l’objet sur lequel travaille la méthode en train d’être exécutée. Si aucun argument ne porte le nom d’un champ de la classe, il n’est pas nécessaire de préfixer le nom du champ de this pour y avoir accès.

Forme complète de déclaration de classe

Avant d’aller plus loin dans l’étude des classes, disons que nous n’avons, jusqu’ici, déclaré des classes que de manière minimale. On peut en effet déclarer une classe de manière bien plus complète en écrivant (les clauses entre crochets étant facultatives) :

[Mod] class NomClasse [: ClasseBase ...] {

... }

Mod peut être remplacé par l’un des mots réservés qui suivent : public, protected, private, sealed, new, final ou abstract. Nous les présenterons plus loin à la section 2.7.

La déclaration d’un champ ou d’une méthode peut être plus complète que ce que nous avons vu jusqu’ici : un champ peut en effet être qualifié de public, de protected ou de private ainsi que de static.

2.2 Construction et destruction d’objet

Les constructeurs

Comme en C++, une méthode particulière, appelée constructeur (ctor en abrégé), est automatiquement exécutée lors de la création de l’objet, c-à-d au moment d’exécuter new. Cette méthode :

• doit porter le nom de la classe ;

• peut admettre zéro, un ou plusieurs arguments ;

• ne renvoie rien, même void doit être omis dans la déclaration de cette fonction. Un constructeur ne doit pas nécessairement être déclaré dans une classe. En l’absence de tout constructeur, le compilateur génère automatiquement un constructeur sans argument. Celui-ci, appelé « constructeur par défaut », n’initialise, si ce n’est à zéro, aucun champ de l’objet sur lequel il travaille. La présence, quoique invisible, du constructeur par défaut permet de créer un objet par :

objet = new NomClasse();

même si la classe NomClasse ne comporte aucune déclaration de constructeur, ce qui était le cas dans tous nos exemples précédents. Contrairement au C++, les parenthèses sont

(10)

toujours requises. Si la déclaration de la classe comporte un constructeur, quel qu’il soit, ce constructeur par défaut n’est pas automatiquement généré. Si ce constructeur comprend des arguments, construire l’objet, comme nous venons de le faire (sans argu-ment au constructeur), constitue, dès lors, une erreur. Cette notion de constructeur par défaut (qui n’apparaît nulle part dans le code que vous manipulez) peut, à la limite, être considérée comme une vue de l’esprit. Il suffit de retenir qu’en l’absence de tout construc-teur, un objet peut toujours être créé comme nous venons de le faire : par new, sans oublier les parenthèses, celles-ci doivent alors être vides.

Une classe peut contenir plusieurs constructeurs. Ceux-ci doivent porter le nom de la classe et être distincts par leurs arguments. Dans l’exemple suivant, nous déclarons deux constructeurs pour la classe Pers. Le premier permet d’initialiser un objet Pers en spéci-fiant un nom et un âge. Dans le second, on ne spécifie qu’un nom. Dans ce cas, on place une « marque » (en fait la valeur -1) dans le champ Age pour signaler que ce champ n’a pas été initialisé :

Pour créer des objets Pers, on écrit (par exemple dans Main) :

Pers p1, p2; // deux objets de la classe Pers p1 = new Pers("Jules", 50); // instanciation

p2 = new Pers("Jim");

p1.Affiche(); // affiche Jules (50) p2.Affiche(); // affiche Jim

Un constructeur sert généralement à initialiser des champs de l’objet que l’on crée. Toutefois il pourrait effectuer des opérations bien plus complexes comme l’ouverture d’un fichier ou d’une voie de communication.

Classe avec deux constructeurs

class Pers {

string Nom; int Age;

// deux constructeurs

public Pers(string N, int A) {Nom = N; Age = A;} public Pers(string N) {Nom = N; Age = -1;}

// une méthode

public void Affiche() {

if (Age != -1) Console.WriteLine(Nom + " (" + Age + ")"); else Console.WriteLine(Nom);

} }

(11)

Constructeur statique

Un constructeur peut être qualifié de static mais de rien d’autre (pas de qualificatif public). Un tel constructeur est automatiquement appelé avant l’utilisation d’un champ statique ou d’une fonction statique de la classe. Un tel constructeur peut initialiser les champs statiques de la classe (voir section 2.4) :

class X { static X() {...} ... }

Les destructeurs en C#

C# semble connaître la notion de destructeur, bien connue des programmeurs C++. Un destructeur porte le nom de la classe préfixé de ~ (caractère tilde) et n’accepte aucun argument :

class Pers {

string Nom; int Age;

public Pers(string N, int A) {Nom = N; Age = A;}

public ~Pers() {Console.WriteLine("Objet en train d'être détruit");} }

Malheureusement, les destructeurs sont bien moins utiles en C# qu’en C++. On peut même dire qu’ils sont complètement inutiles, ce qui est très regrettable. L’environnement .NET s’occupe certes de libérer automatiquement les objets qui n’ont plus cours (on s’en félicitera) mais il le fait uniquement quand cela l’arrange. Le destructeur n’est malheu-reusement pas automatiquement appelé quand l’objet quitte sa zone de validité. On dit que la destruction n’est pas déterministe : on ignore quand elle aura lieu et il n’y a même aucune garantie qu’un destructeur soit jamais appelé. Les programmeurs C++ ont l’habi-tude d’allouer des ressources dans le constructeur, par exemple en ouvrant un fichier, et de les libérer dans le destructeur, par exemple en fermant le fichier. Le programmeur C++ sait que le destructeur sera automatiquement appelé quand l’objet quittera sa zone de validité, par exemple à la sortie de la fonction si l’objet est local à la fonction.

Le programmeur C# n’a pas cette certitude. Pour libérer des ressources créées ou acqui-ses dans le constructeur, il doit explicitement appeler une fonction qui effectue ce travail. À lui de ne pas oublier d’appeler cette fonction et de le faire au bon moment, soit juste avant la destruction logique de l’objet. Toutes les « bonnes raisons » données par les architectes de .NET pour ne pas implémenter les destructeurs déterministes comme en C++ sont tout simplement inacceptables. Que Java fonctionne sans destruction déter-ministe (c-à-d sans destructeur appelé au moment même de la destruction logique de l’objet) ne constitue en aucune façon une excuse.

(12)

Microsoft recommande d’effectuer les destructions (par exemple des fermetures de fichiers) dans une fonction appelée Dispose (une fonction qui n’accepte aucun argument et qui ne renvoie rien). Pour répondre à l’incompréhension, voire à la colère des program-meurs C++, Microsoft a introduit une syntaxe pour provoquer l’appel automatique de cette fonction Dispose. La classe doit pour cela implémenter l’interface IDisposable :

class Pers : IDisposable {

... // champs, constructeurs et méthodes public void Dispose()

{

... // détruire explicitement ce qui doit l'être }

}

Pour que la fonction Dispose appliquée à un objet Pers soit automatiquement appelée, on écrit (using n’a ici rien à voir avec les espaces de noms) :

Pers p = new Pers(); using (p)

{

... // instructions agissant éventuellement sur p } // fin de la zone de validité du using

La fonction Dispose appliquée à l’objet p est automatiquement appelée à la fin de la zone de validité du using.

Il est possible de créer une zone de validité pour plusieurs objets et donc de faire appeler automatiquement Dispose pour chacun de ces objets :

Pers p = new Pers(); Animal a = new Animal(); using (p) using (a) {

... // instructions agissant éventuellement sur p et a }

Les fonctions a.Dispose() et p.Dispose(), dans l’ordre inverse des using, seront automatique-ment appelées lorsque le programme tombera sur l’accolade de fin du using (cette dernière expression constitue une image puisque le programme est compilé et non interprété).

2.3 Les tableaux d’objets

Syntaxe très différente de celle du C++. Vous pouvez déclarer et allouer un tableau d’objets (tous les éléments du tableau devant appartenir à la même classe) mais les cellu-les du tableau ne sont pas automatiquement initialisées par appel du constructeur, le compilateur ignore quel constructeur appeler puisque la syntaxe de new ne permet pas de le spécifier. Vous devez donc appeler explicitement un constructeur pour chacune des cellules du tableau.

(13)

Par conséquent, pour créer un tableau de dix objets d’une classe Pers, il faut écrire : Pers[] tp; // déclaration de la référence au tableau tp = new Pers[10]; // allocation du tableau

for (int i=0; i<tp.Length; i++) // pour chaque élément du tableau tp[i] = new Pers(); // appel du constructeur

Si le constructeur de la classe Pers réclame des arguments, il faut évidemment modifier la dernière ligne en conséquence.

Dire que toutes les cellules d’un tableau doivent être de même type peut laisser croire à des restrictions. Comme la classe Object est mère de toutes les classes, on peut écrire :

object[] to = new object[2]; to[0] = new Pers();

to[1] = new Animal();

La première cellule de to est de type Pers et la seconde de type Animal. Pour appeler la fonction Affiche appliquée à to[0], il faut écrire :

((Pers)to[0]).Affiche();

Tout ce que nous avons vu à la section 1.8 concernant les tableaux de types primitifs (tableaux d’entiers, de réels, etc.) s’applique aux tableaux d’objets. Nous continuerons l’étude des tableaux à la section 3.5 avec la classe Array qui est la classe de base des tableaux.

2.4 Champs et méthodes statiques

Légère différence dans la syntaxe par rapport au C++. Comme en C++, des champs mais aussi des méthodes peuvent être qualifiés de static. Contrairement aux autres champs et autres méthodes de la classe, ces champs et ces méthodes existent et sont donc accessibles indépendamment de toute existence d’objet de la classe.

Pour qualifier un champ ou une méthode de static, on écrit : class X

{

int a; // champ non statique static int b; // champ statique void f() {....} // méthode non statique static void g() {b++;} // méthode statique }

Un champ statique d’une classe :

• existe indépendamment de tout objet de la classe et existe même si aucun objet de la classe n’a encore été créé ;

• est partagé par tous les objets de la classe ;

• est accessible par ncl.nattrstat où ncl désigne le nom de la classe et nch le nom du champ statique (autrement dit par le nom de la classe suivi d’un point suivi du nom du champ) ;

(14)

• peut être initialisé et manipulé dans une méthode de la classe comme n’importe quel champ.

Une méthode statique peut être appelée même si aucun objet de la classe n’a encore été créé : appelez une telle méthode par ncl.nf (sans oublier les arguments) où ncl désigne le nom de la classe et nf le nom de la méthode statique (WriteLine de la classe Console est un exemple de fonction statique).

Contrairement au C++, cette méthode ne peut pas être appelée par nobj.nf où nobj dési-gne un objet de la classe.

Les méthodes statiques et non statiques doivent respecter les règles suivantes :

• une méthode statique n’a accès qu’aux champs statiques de sa classe (toute référence à a dans g serait sanctionnée par le compilateur) ;

• une méthode statique peut appeler une autre méthode statique (de sa classe ou d’une autre) mais ne peut pas appeler une méthode non statique ;

• une méthode non statique d’une classe (par exemple f dans l’exemple précédent) a accès à la fois aux champs statiques et aux champs non statiques de sa classe. Ainsi, f peut modifier a et b.

La plupart des méthodes de la classe Math (présentée à la section 3.1 et qui regroupe de nombreuses fonctions mathématiques) sont qualifiées de static. Elles peuvent donc être appelées sans devoir créer un objet de la classe Math. Pour calculer la racine carrée d’un nombre, on écrit par exemple (Sqrt étant la méthode statique de la classe Math qui renvoie la racine carrée d’un nombre au format double) :

double a, b=12.3;

a = Math.Sqrt(b); // a prend la valeur 3.5071

Une classe peut ne contenir que des champs statiques, ce qui permet de simuler les varia-bles globales d’un programme C/C++.

2.5 L’héritage

Par rapport au C++ : mêmes principes (mais pas d’héritage multiple) et une syntaxe différente.

Composition

Une classe peut contenir un objet d’une autre classe, ce que nous avons d’ailleurs déjà fait à plusieurs reprises en incorporant des objets string. Cette technique, appelée compo-sition, permet de créer une classe plus complexe à partir d’une ou de plusieurs autres classes. Une classe A peut également contenir la déclaration d’une autre classe B. On parle alors de classes imbriquées (nested classes en anglais). La classe B ne peut alors être utilisée que dans A. La classe B n’est pas vue de l’extérieur de A.

(15)

Dans le fragment qui suit, la classe B est imbriquée dans la classe A (pour les noms des variables : ch pour champ, pr pour privé et pu pour public) :

class A {

public class B {

int chpr; public int chpu;

public B(int arg) {chpr = arg; chpu = 10*arg;} public int getchpr() {return chpr;}

}

public A(int arg) {prb = new B(2*arg); pub = new B(10*arg);} public int getchprdeB() {return prb.getchpr();}

B prb; public B pub; }

La classe A contient deux champs de la classe B, l’un privé et l’autre public. On peut créer un objet A et écrire (puisque getchprdeB, pub et chpu sont publics) :

A a = new A(5);

int n = a.getchprdeB(); n = a.pub.chpu;

Un objet de la classe B ne peut pas être créé en dehors de la classe A.

Les objets privés de la classe B restent privés pour A. Le champ chpr de la classe B incorporé dans a est privé. Il n’est donc pas accessible par a.pub.chpr, c’est pour cette raison que nous avons introduit la fonction getchpr. De même, écrire a.prb.chpu, constitue une erreur puisque prb est privé.

public devant class B aurait pu être omis si la classe B n’avait contenu que des champs privés.

Notion d’héritage

À partir d’une classe (appelée classe mère, classe de base ou encore superclasse), on peut en créer une autre, étendant ainsi les possibilités de la classe mère. La classe ainsi créée est appelée classe dérivée ou classe fille ou encore sous-classe. On peut imaginer une classe de base Véhicule et une classe dérivée Automobile. La classe Automobile constitue une spécialisation de la classe Véhicule. Une automobile a les caractéristiques générales d’un véhicule mais aussi des caractéristiques propres à une automobile.

Dans la classe dérivée, on peut ajouter de nouvelles méthodes et de nouveaux champs (par exemple Cylindrée pour la classe Automobile, ce qui n’aurait pas de sens dans une classe aussi générale que Véhicule). On peut également déclarer des champs et des métho-des qui portent le même nom que dans la classe mère. Cette technique est très utilisée car elle permet de modifier le comportement des classes de base. Cette technique revêt une importance considérable et explique le succès des techniques de programmation orientée objet.

(16)

Moyennant des restrictions dont nous allons bientôt parler (voir les protections à la section 2.7), on peut dire qu’un objet d’une classe dérivée comprend :

• les champs qui sont propres à la classe dérivée mais aussi ceux qui sont propres à la classe mère ;

• les méthodes qui sont propres à la classe dérivée mais aussi celles qui sont propres à la classe mère.

Dans la classe dérivée, on ne fait d’ailleurs aucune distinction (en oubliant certes les protections, voir la section 2.7) entre champs et méthodes propres à la classe dérivée et ceux qui sont propres à la classe mère.

Pas d’héritage multiple en C#

Contrairement au C++, C# n’accepte pas l’héritage multiple. En C#, une classe ne peut avoir qu’une seule classe de base directe. Rien n’empêche que cette classe de base soit elle-même dérivée d’une autre classe de base et ainsi de suite (ce qui donne une hiérarchie grand-mère, mère, fille et ainsi de suite).

Exemple d’héritage

Il est maintenant temps d’illustrer le sujet à l’aide de petits exemples tout en allant plus loin dans la théorie. Partons d’une classe de base Pers.

Dans la classe Pers, nous avons qualifié les champs Nom et Age de protected. Ils pourront ainsi être utilisés dans des méthodes de classes dérivées de Pers. Sans ce qualificatif protected, seules les méthodes de Pers (le constructeur et Affiche) auraient pu utiliser Nom et Age. À partir de cette classe Pers très générale, nous allons créer une classe plus spécialisée. Un Français a évidemment toutes les caractéristiques d’une personne. Mais il a aussi des caractéristiques qui lui sont propres, comme le département dans lequel il réside. À partir de la classe Pers, nous allons dériver (ou « étendre »si vous préférez) la classe Français, sans oublier que le ç (lettre c cédille) est autorisé dans un identificateur. Dans la déclaration ci-après, les deux points indiquent la classe à partir de laquelle on dérive, c-à-d la classe de base. Un objet de la classe Français est donc constitué d’un objet Pers auquel on a greffé un nouveau champ (en l’occurrence Département, de type string). Dans le

Classe de base

class Pers {

protected string Nom; protected int Age;

public Pers(string N, int A) {Nom = N; Age = A;}

public void Affiche() {Console.WriteLine(Nom + " (" + Age + ")");} }

(17)

constructeur de la classe Français, on commence par appeler le constructeur de la classe de base, ce qui initialise la partie Pers d’un objet Français (le mot réservé base fait réfé-rence au constructeur de la classe de base). Dans le constructeur de la classe Français, on initialise ensuite les champs propres à cette classe Français.

Nous avons donc étendu la classe Pers, qui évidemment existe toujours, en lui ajoutant un champ : Département, de type string. Dans la classe dérivée qu’est Français, nous avons aussi une méthode (Affiche) qui porte le même nom que dans la classe de base.

L’appel du constructeur de la classe de base (par : base) peut être absent. Dans ce cas, le compilateur fait automatiquement appeler le constructeur sans argument de la classe de base (nous n’échappons jamais à l’appel du constructeur de la classe de base). Rappelons qu’un tel constructeur est automatiquement créé par le compilateur en l’absence de tout autre constructeur.

Une erreur de syntaxe est signalée si vous n’appelez pas explicitement le constructeur de la classe de base et si le constructeur sans argument est absent dans la classe de base (le constructeur sans argument n’est automatiquement généré par le compilateur qu’en l’absence de tout autre constructeur). Autrement dit : si au moins un constructeur a été défini dans la classe de base, vous devez d’abord appeler (par : base) l’un de ces constructeurs.

Un objet Français est créé en écrivant :

Français marius = new Français("Marius", 25, "Bouches-du-Rhône"); marius.Affiche(); // Marius âgé de 25 ans (Bouches du Rhône)

marius étant un objet Français, c’est la fonction Affiche de la classe Français qui est appelée. Si vous compilez un programme reprenant les classes Pers, Français et une classe de programme (c’est le cas du programme ci-après), vous recevrez un avertissement pour la fonction Affiche de la classe Français. Cette fonction « cache » en effet la fonction Affiche de Pers (même nom et mêmes arguments) : sans Affiche de Français, marius.Affiche()

Classe dérivée

class Français : Pers {

string Département;

public Français(string N, int A, string D) : base(N, A) {

Département = D; }

public void Affiche() {

Console.WriteLine(Nom + " âgé de " + Age + " ans (" + Département + ")"); }

(18)

aurait fait exécuter Affiche de Pers. Pour bien montrer qu’il s’agit d’une nouvelle fonction Affiche, le compilateur vous invite à qualifier Affiche de Français de new :

Redéfinition de méthode

Dans notre classe dérivée, nous avons une fonction Affiche qui porte le même nom que dans la classe de base. Nous allons envisager différents cas de création d’objets.

Classe dérivée avec fonction qualifiée de new

class Français : Pers {

string Département;

public Français(string N, int A, string D) : base(N, A) {

Département = D; }

public new void Affiche() {

Console.WriteLine(Nom + " âgé de " + Age + " ans (" + Département + ")");

} }

Redéfinition de méthode Ex2.cs

using System; class Pers {

protected string Nom; protected int Age;

public Pers(string N, int A) {Nom = N; Age = A;} public void Affiche()

{

Console.WriteLine(Nom + " (" + Age + ")"); }

}

class Français : Pers {

string Département;

public Français(string N, int A, string D) : base(N, A) {

Département = D; }

public new void Affiche() {

Console.WriteLine(Nom + " âgé de " + Age + " ans (" + Département + ")"); }

(19)

Analysons les diverses créations d’objets ainsi que les divers appels de la méthode Affiche. Lors de l’exécution de p1.Affiche(), c’est la méthode Affiche de la classe Pers qui est exécutée. Lors de l’exécution de p2.Affiche(), c’est Affiche de la classe Français qu(suite)i est exécutée. Normal puisque p1 et p2 sont respectivement des objets des clas-ses Pers et Français. Si la méthode Affiche n’avait pas été déclarée dans la classe Français, p2.Affiche() aurait fait exécuter Affiche de la classe Pers.

Le qualificatif new pour la fonction Affiche de la classe Français n’est pas obligatoire mais l’oublier vaut un avertissement. Il signale que Affiche redéfinit une fonction d’une classe de base et que celle-ci est maintenant « cachée » par sa nouvelle implémentation dans la classe Français.

Le compilateur procède en effet de la sorte : si une méthode f est appliquée à un objet de la classe C, le compilateur recherche d’abord f dans la classe C. S’il la trouve (encore faut-il qu’elle soit publique), le compilateur génère du code pour faire exécuter cette méthode f. Sinon, il recherche f (même nom et mêmes types d’arguments) dans la classe de base de C. Si elle n’est toujours pas trouvée, le compilateur recherche f dans la classe de base de la précédente et ainsi de suite. À cette occasion, on dit que le compilateur remonte la hiérar-chie des classes. Le compilateur signale une erreur de syntaxe s’il ne trouve aucune méthode f (même nom et mêmes arguments) dans aucune des classes appartenant à la hiérarchie de la classe C.

Pas de problème donc pour p1 et p2. Mais il reste le cas de p3.

Un objet d’une classe dérivée est un objet d’une classe de base mais l’inverse n’est pas vrai. Les deux dernières lignes de l’exemple précédent (avec instanciation et affichage de p3) réclament plus d’attention. p3 a certes été déclaré comme un objet de la classe Pers mais lors de l’instanciation de p3, il devient clair que l’on crée un objet de la classe Français. C# autorise une telle construction car tout objet de la classe Français est également un objet de la classe Pers : tout Français est en effet une personne, l’inverse n’étant pas nécessairement vrai.

Redéfinition de méthode (suite) Ex2.cs

class Prog {

static void Main () {

Pers p1, p3; Français p2;

p1 = new Pers("Jim", 36); p1.Affiche();

p2 = new Français("Jacques", 65, "Corrèze"); p2.Affiche();

p3 = new Français("François", 75, "Nièvre"); p3.Affiche();

(20)

De manière générale, tout objet d’une classe dérivée est aussi un objet de sa classe de base (et plus précisément même de l’une de ses classes de base). p3 devient donc, dans les faits et à partir de l’instanciation par new, un objet de la classe Français.

Par défaut, le compilateur ne l’entend malheureusement pas de cette oreille quand il s’agit d’appeler des fonctions de la classe : p3 a été déclaré de type Pers et pour exécuter :

p3.Affiche();

le compilateur se base sur la définition stricte de p3 et fait dès lors exécuter Affiche de Pers. Le compilateur n’a pas tenu compte du véritable type de p3. Pour comprendre cette méprise, considérons une instruction if et imaginons qu’en fonction de l’évaluation de la condition, on assigne dans p3 soit un objet Pers, soit un objet Français. Le compilateur lui-même ne peut pas savoir que p3 contient réellement un objet Français. Ce n’est qu’en cours d’exécution de programme que cette constatation peut être faite, et, par défaut, le compilateur ne génère pas de code qui permet de faire cette constatation.

Toutefois, les fonctions virtuelles permettent de résoudre ce problème.

Les fonctions virtuelles

Pour tenir compte du véritable type de p3 au moment d’exécuter p3.Affiche(), il faut : • qualifier Affiche de virtual dans la classe de base ;

• qualifier Affiche de override dans la classe dérivée. Réécrivons le programme précédent en conséquence :

Méthode virtuelle Ex3.cs

using System; class Pers {

protected string Nom; protected int Age;

public Pers(string N, int A) {Nom = N; Age = A;} public virtual void Affiche()

{

Console.WriteLine(Nom + " (" + Age + ")"); }

}

class Français : Pers {

string Département;

public Français(string N, int A, string D) : base(N, A) {

Département = D; }

(21)

L’effet est le suivant : au moment d’exécuter p3.Affiche(), du code injecté dans le programme se rend compte du véritable type de p3 et appelle Affiche de Français dans le cas où p3 fait effectivement référence à un objet Français (alors que formellement il désigne un objet Pers). On peut dire que, par la magie des fonctions virtuelles, la référence p3 s’est adaptée au véritable type de l’objet p3.

Le programme précédent provoque dès lors les affichages suivants :

Jim (36)

Jacques âgé de 65 ans (Corrèze) François âgé de 75 ans (Nièvre) On peut écrire :

Pers p3; ....

p3 = new Français("Line", 65, "Nord"); // p3 : objet Français p3.Affiche();

p3 = new Pers("Bob", 20); // p3 est maintenant un objet Pers p3.Affiche();

ce qui provoque les affichages ci-après : Line âgé de 65 ans (Nord)

Bob (20)

Si on peut instancier un objet p3, de type Pers, par p3 = new Français, nous n’aurions par contre pas pu instancier p2, de type Français, par p2 = new Pers, pour la bonne raison que si un Français est une personne, une personne n’est pas nécessairement un Français.

Méthode virtuelle (suite) Ex3.cs

public override void Affiche() {

Console.WriteLine(Nom + " âgé de " + Age + " ans (" + Département + ")"); }

}

class Prog {

static void Main () {

Pers p1, p3; Français p2;

p1 = new Pers("Jim", 36); p1.Affiche();

p2 = new Français("Jacques", 65, "Corrèze"); p2.Affiche();

p3 = new Français("François", 75, "Nièvre"); p3.Affiche();

} }

(22)

Pour résumer, un objet s’adapte à la classe réellement instanciée car un objet d’une classe dérivée est avant tout un objet de sa classe de base. L’inverse n’est cependant pas vrai et le compilateur dans un tel cas signalerait l’erreur de syntaxe suivante :

Pers p1, p2; Français p3, p4;

p1 = new Pers("Jim", 36); // Correct p2 = new Français("Jules", 45, "Lot"); // Correct p3 = new Français("François", 75, "Nièvre"); // Correct

p4 = new Pers("Bob", 20); // Erreur de syntaxe

car p4.Département ne correspondrait alors à rien.

.NET libère les objets pour vous

Plus haut, nous avons signalé que, dans la réalité du code généré, p3 est un pointeur (mais ne le répétez surtout pas). Lors de la première instanciation par new Français, une zone de mémoire est associée à p3 et contient les différentes informations relatives à un objet Français (Nom, Age et Département). Lors de la seconde instanciation par new Pers, une autre zone de mémoire est associée à p3 (p3, lui, ne changeant que de contenu). La zone de mémoire précédemment associée à p3 est alors automatiquement signalée « zone libérée par le programme et susceptible d’être réutilisée ». En C++, il serait impératif d’exécuter delete p3 pour libérer la première zone de mémoire sans quoi nous allons au devant de graves problèmes de saturation de mémoire. En C#, tout cela est réalisé automatiquement. Le ramasse-miettes de C#, qui s’exécute parallèlement aux programmes mais avec une faible priorité, libérera effectivement cette zone quand bon lui semblera (dans les faits : quand un besoin en espace mémoire se fera sentir).

Appel de méthodes « cachées » par la redéfinition

La méthode Affiche de la classe Français redéfinit celle de la classe Pers. Tout appel de la méthode Affiche appliquée à un objet Français « cache » la méthode Affiche de Pers. Rien n’empêche cependant que Affiche de Français appelle Affiche de Pers. Il suffit pour cela d’écrire :

base.Affiche(); // appel de la fonction Affiche de la classe de base

Il en va de même pour les champs. Rien n’empêche en effet qu’un champ d’une classe dérivée porte le même nom (mais pas nécessairement le même type) que dans sa classe de base ou l’une de ses classes de base. Le champ de la classe de base est alors « caché ». À partir d’une méthode de la classe dérivée, on y a cependant encore accès en le préfixant de base., comme pour les méthodes.

(23)

Dans une méthode, this. et base. peuvent préfixer un nom de champ ou de méthode (this ne s’appliquant cependant pas aux méthodes statiques car elles ne travaillent pas sur un objet) :

• this.xyz : fait référence au champ ou à la méthode xyz de cette classe (cette forme n’étant généralement utilisée que dans le cas où un argument de la méthode s’appelle également xyz).

• base.xyz : fait référence au champ ou à la méthode xyz d’une des classes de base. En revanche, il n’est pas possible d’appeler une fonction d’une classe grand-mère ou d’accéder directement à un champ d’une classe grand-mère. Écrire :

base.base.Affiche();

constitue en effet une erreur de syntaxe même si la classe courante a une classe grand-mère qui a implémenté Affiche.

Quel est le véritable objet instancié dans une référence ?

Nous avons vu que dans un objet, appelons-le p, déclaré de type Pers (une classe de base), on pouvait instancier soit un objet Pers soit un objet Français (classe dérivée de Pers mais il pourrait s’agir de n’importe quelle classe qui a Pers parmi ses ancêtres). Nous avons également vu qu’avec les fonctions virtuelles, l’objet p en question s’adapte automatique-ment à la classe réelleautomatique-ment instanciée. Mais comautomatique-ment déterminer si p contient un objet Pers ou un objet Français ? Le mot réservé is nous fournit cette information.

Pour illustrer is, intéressons-nous à la nationalité du conjoint d’un Français. Ajoutons pour cela la méthode Mariage à la classe Français :

class Français : Pers {

....

public void Mariage(Pers Conjoint) {

if (Conjoint is Français)

Console.WriteLine("Mariage avec compatriote"); else Console.WriteLine("Mariage avec étranger"); }

}

Dans la fonction Main, on écrit :

Français f = new Français("Charles", 30, "Lot"); Pers p1 = new Français("Marie", 25, "Oise"); f.Mariage(p1);

Pers p2 = new Pers("Conchita", 20); f.Mariage(p2);

(24)

ce qui provoque les affichages suivants :

Mariage avec compatriote Mariage avec étranger

car dans la méthode Mariage, is aura signalé le véritable type de l’argument.

Deux références pour un même objet

Un objet peut être « copié » dans un autre (mais attention au sens donné à « copié »). Considérons le fragment de programme suivant (qui ne fait cependant pas une copie) :

Pers p1, p2;

p1 = new Pers("Jim", 36); p2 = p1;

p1 et p2 ne constituent pas deux objets distincts ! Il s’agit de deux références pour un même objet (souvenez-vous que les références ne sont en fait rien d’autre que des poin-teurs). p1 et p2 constituent certes des variables différentes mais après exécution de p2 = p1, p1 et p2 « pointent » sur la même zone de mémoire, celle qui contient (Jim, 36). Par consé-quent, exécuter p1.Age++ a également pour effet d’incrémenter p2.Age puisqu’il s’agit du même objet. Pour effectuer une copie d’objet, vous devez instancier un objet p2 et copier dans chaque champ de p2 les champs correspondants de p1.

Pour effectuer une véritable copie d’objet, la classe Pers doit implémenter l’interface ICloneable et offrir une méthode Clone :

class Pers : ICloneable {

...

public object Clone() {return new Pers(Nom, Age);} }

Une copie d’objet peut dès lors être effectuée par :

p2 = (Pers)p1.Clone();

Comparaison d’objets

Si l’on compare deux objets par p1==p2, ce sont par défaut les références qui sont comparées, pas les contenus des objets. Pour que deux objets Pers puissent être comparés sur leurs contenus, il faut :

• redéfinir la méthode Equals de la classe Object, ce qui permet de comparer p1 et p2 par p1.Equals(p2) ;

• redéfinir les opérateurs == et !=, ce qui permet de comparer p1 et p2 par == ou != ; • redéfinir la fonction GetHashCode de Object (sinon, cela peut provoquer un problème si

(25)

Notre classe Pers devient dès lors :

Le qualificatif

sealed

Le qualificatif sealed peut être appliqué à une classe pour empêcher toute dérivation à partir de cette classe. Par exemple :

sealed class Français : Pers {

.... }

2.6 Surcharge d’opérateur

Nous avons vu qu’une fonction peut être redéfinie dans une classe dérivée. En exécutant cela, nous changeons la signification de la fonction par rapport à la fonction de la classe de base. Les opérateurs (+, -, etc.) peuvent également être redéfinis (ou surchargés si vous préférez). Tous les opérateurs ne peuvent cependant pas être surchargés (par exemple, l’opérateur = ne peut être surchargé). La raison de cette limitation est simple : ne pas rendre inutilement les programmes encore plus illisibles. Seulement certains opérateurs unaires et certains opérateurs binaires peuvent être surchargés. Les opérateurs unaires sont ceux qui n’opèrent

Classe Pers pour comparaisons sur contenus

public class Pers {

string Nom; int Age;

public Pers(string N, int A) {Nom=N; Age=A;} public override bool Equals(object o) {

Pers p = (Pers)o;

return Nom==p.Nom && Age==p.Age; }

public static bool operator==(Pers p1, Pers p2) {

return p1.Equals(p2); }

public static bool operator!=(Pers p1, Pers p2) {

return !p1.Equals(p2); }

public override int GetHashCode() {

return this.ToString().GetHashCode();} }

(26)

que sur un seule opérande (par exemple – pour le changement de signe) tandis que les opérateurs binaires opèrent sur deux opérandes (par exemple – pour la soustraction).

Les opérateurs +=, -=, *=, /=, &&, || et new ne peuvent pas être redéfinis.

Pour redéfinir un opérateur, il faut écrire une fonction statique. Celle-ci doit obéir à certaines règles :

Pour illustrer le sujet, nous allons écrire une classe Heure (composée de deux champs privés : H pour les heures et M pour les minutes) et redéfinir l’opérateur – pour calculer la durée, en minutes, entre deux heures. Pour simplifier, nous considérerons que l’on reste dans la même journée. La méthode statique operator – (c’est son nom) accepte deux arguments de type Heure et renvoie la différence d’heures exprimée en minutes.

Opérateurs susceptibles d’être redéfinis Opérateurs unaires + - ! ~ ++ --

Opérateurs binaires + - * / % & | ^ << >>

Règles régissant les redéfinitions d’opérateurs

Opérateur unaire ◊ la fonction ne peut admettre qu’un seul argument, du type de sa classe , . elle doit renvoyer une valeur, du type de sa classe également.

Opérateur binaire ◊ la fonction doit admettre deux arguments, le premier devant être du type de sa classe et le second de n’importe quel type,

◊ la valeur de retour peut être de n’impor te quel type.

Redéfinition d’opérateur Ex4.cs

using System; class Heure {

int H, M;

public Heure(int aH, int aM) {H = aH; M = aM;} public static int operator -(Heure h1, Heure h2) { return h1.H*60 + h1.M - h2.H*60 - h2.M; } } class Prog {

static void Main() {

Heure maintenant, tantôt;

maintenant = new Heure(11, 05); tantôt = new Heure(10, 45); int n = maintenant - tantôt;

} }

(27)

On peut désormais calculer la différence de deux objets Heure avec une syntaxe très naturelle.

Opérateurs de conversion

Les opérateurs de conversion peuvent également être redéfinis. Supposons que l’on trouve utile, d’un point de vue efficacité du travail de programmation, de pouvoir écrire (on désire ici convertir l’objet Heure de l’exemple précédent en un entier) :

int n = maintenant;

où n contiendrait le nombre de minutes depuis minuit de l’objet maintenant (objet de type Heure). Le compilateur va refuser l’instruction car il ne connaît pas la règle de conversion d’un objet Heure en int.

Deux types de conversion sont possibles : les conversions implicites et les conversions explicites. Avec les conversions explicites, le programmeur doit explicitement marquer son intention d’effectuer une telle conversion (avec un casting). Les conversions implicites peuvent s’avérer plus insidieuses et généralement plus dangereuses. À ces deux types de conversion correspondent les mots réservés implicit et explicit.

Pour pouvoir convertir implicitement un objet Heure en int, on doit écrire (dans la classe Heure) une fonction statique qui doit s’appeler operator int et qui renvoie évidemment un int (ce qu’il ne faut dès lors pas spécifier) :

public static implicit operator int(Heure h) {

return h.H*60 + h.M; }

Les conversions implicites peuvent provoquer des ambiguïtés qui ne sont pas toujours évidentes (le compilateur a ses raisons qui ne sont pas toujours celles du programmeur). Si le compilateur arrive à la même conclusion, il vous le signale par une erreur de syntaxe. Sinon, la conversion que le programmeur croit être effectuée ne l’est pas, et une conversion non désirée est alors effectuée à l’insu du programmeur.

Considérons l’exemple suivant (conversion d’un objet Heure en une chaîne de caractères) qui ne pose pas de problème (le programme affichera par exemple 10:45) :

class Heure {

int H, M;

public Heure(int aH, int aM) {H = aH, M = aM;} public static implicit operator string(Heure h)

Conversions explicites et implicites Conversion explicite int n = (int)maintenant;

(28)

{ return h.H + ":" + h.M; } } ... Console.WriteLine(maintenant);

Mais il y a ambiguïté, avec erreur de syntaxe, si nous ajoutons (le compilateur ignore s’il doit convertir un objet Heure en un int ou un string) :

class Heure {

int H, M;

public Heure(int aH, int aM) {H = aH, M = aM;} public static implicit operator string(Heure h) {

return h.H + ":" + h.M; }

public static implicit operator int(Heure h) { return h.H*60 + h.M; } } ... Console.WriteLine(maintenant);

L’ambiguïté est levée en forçant des conversions explicites (avec le mot réservé explicit au lieu d’implicit) et en forçant le programmeur à écrire, par exemple :

Console.WriteLine((string)maintenant);

2.7 Protections sur champs et méthodes

Les qualificatifs public, protected, private et internal peuvent être appliqués aux classes ainsi qu’aux champs et aux méthodes d’une classe.

Une classe peut être qualifiée (juste avant le mot réservé class) de : • public : on peut créer partout un objet de la classe.

• rien du tout : on peut créer un objet de la classe mais dans le même assemblage uniquement (même fichier source de programme).

Une méthode ou un champ d’une classe peut être qualifié de :

• Public : toutes les méthodes, quelle que soit leur classe (du même espace de nom ou pas mais à condition qu’elles aient accès à la classe), ont accès à un tel champ ou peuvent appeler une telle méthode.

Les constructeurs sont généralement qualifiés de public, sinon des objets de cette classe ne pourraient être créés, ce qui est parfois voulu pour empêcher qu’un objet d’une classe déterminée ne soit créé. C’est le cas des classes qui ne contiennent que des champs et des méthodes statiques pour simuler les champs et fonctions globales du C/C++.

(29)

• private : seules les méthodes (quelles qu’elles soient) de la classe ont accès à un tel champ ou à une telle méthode. Même les classes dérivées n’y ont pas accès.

Omettre le qualificatif revient à spécifier private.

• protected : seules les méthodes de la classe et des classes dérivées de celle-ci (qu’elles fassent partie ou pas du même espace de noms) ont accès à un tel champ ou peuvent appeler une telle méthode.

• internal : seules les méthodes (quelles qu’elles soient) du même assemblage ont accès à un tel champ ou peuvent appeler une telle méthode.

2.8 Classes abstraites

Une classe abstraite contient la définition d’une fonction (son prototype si vous préférez) mais pas son implémentation. Cette fonction devra être implémentée dans une classe dérivée.

Une classe abstraite doit être qualifiée de abstract et ne peut être instanciée. Autrement dit : on ne peut pas créer d’objet d’une classe abstraite. Les méthodes dont on ne donne que le prototype dans la classe abstraite doivent également être qualifiées d’abstract.

Classes abstraites Ex5.cs

using System; abstract class A {

int i;

public A(int ai) {i = ai;} public abstract void F(); }

class B : A {

public B(int i) : base(i) {}

public override void F() {Console.WriteLine("F de B "); } }

class Prog {

static void Main() {

B b = new B(5); b.F();

} }

(30)

On ne peut pas créer d’objet d’une classe abstraite. Pour pouvoir créer un objet, il faut partir d’une classe complète quant à son implémentation.

2.9 Les interfaces

Une interface définit les méthodes que des classes (celles qui implémentent l’interface en question) doivent implémenter. Les interfaces constituent souvent une alternative aux classes abstraites. Dans l’interface, on trouve uniquement le prototype de ces méthodes, on ne trouve pas de code. On ne peut pas non plus trouver de qualificatif comme public, private ou protected car c’est à la classe implémentant l’interface de décider. Si une fonc-tion de l’interface (dont on ne donne pourtant que le prototype) accepte un ou plusieurs arguments, il faut curieusement donner un nom à ces arguments.

Déclarons l’interface IB (l’usage est de commencer le nom d’une interface par I) :

interface IB {

void f1(int a); // donner un nom à l'argument void f2();

}

Les classes qui implémentent l’interface IB devront implémenter les fonctions f1 et f2. Autrement dit, elles devront contenir le corps de ces fonctions et il vous appartient d’écrire ces instructions dans les classes implémentant IB. Une interface ne peut contenir que des prototypes, elle ne peut contenir aucun corps de fonction ni aucun champ (contrairement aux classes abstraites qui, elles, peuvent contenir des champs et des implémentations de fonctions).

Tout comme pour les classes, une interface peut être dérivée d’une autre interface. L’inter-face dérivée comprend alors ses propres méthodes ainsi que celles de l’interL’inter-face de base (sans oublier qu’il s’agit uniquement des prototypes). Contrairement aux classes, pour lesquelles la dérivation multiple n’est pas autorisée, une interface peut dériver de plusieurs interfaces : il suffit alors de séparer par virgule les noms des différentes interfaces de base. Une classe peut également implémenter plusieurs interfaces.

Classe implémentant une interface

Une classe signale qu’elle implémente une interface (ou plusieurs interfaces) de la manière suivante :

class X : IB {

... // champs et méthodes propres à la classe X void f1(int a) {...} // corps de f1, qu'il faut écrire

void f2() {...} // corps de f2, qu'il faut écrire }

(31)

ou, si la classe implémente plusieurs interfaces : class X : IB, IC

{

... // champs et méthodes propres à X void f1(int a) {...} // corps des méthodes provenant de IB void g1() {...} // corps des méthodes provenant de IC }

Dans le programme suivant, la classe A implémente l’interface IB (l’ordre d’apparition des interfaces, des classes et de Main n’a aucune importance) :

Référence à une interface

Dans l’exemple qui précède, a désigne une référence à l’objet A. a peut être converti en une référence (baptisée ici rib) à l’interface IB :

IB rib = a;

Les méthodes l’interface IB, et uniquement celles-là, peuvent maintenant être appelées via rib : rib.f(); Implémentation d’interface using System; interface IB { void f(); void g(); } class A : IB {

public void f() {Console.WriteLine("f de A ");} public void g() {Console.WriteLine("g de A ");} }

class Prog {

static void Main() { A a = new A(); a.f(); a.g(); } }

(32)

Classe implémentant plusieurs interfaces

Si une classe A implémente les interfaces IB et IC, il est possible que des fonctions de IB portent le même nom que des fonctions de IC :

interface IB { void f(); void g(); } interface IC {

double f(int a); void h(); }

Si une classe A implémente les interfaces IB et IC, A doit implémenter f de IB et f de IC. Il y a ambiguïté puisque l’on retrouve f à la fois dans IB et dans IC. Peu importe la signature (type et nombre des arguments) de ces fonctions, il y a ambiguïté à partir du moment où deux noms sont identiques.

Pour résoudre le problème, la classe A doit spécifier qu’elle implémente f de A et f de B. Ces deux fonctions ne peuvent être appelées que via des références d’interfaces :

class A : IB, IC {

void IB.f() {...} double IC.f(int a) {...} }

Pour exécuter ces fonctions appliquées à un objet de la classe A, on doit écrire : A a = new A();

IB rib = a;

rib.f(); // exécution de f de IB appliquée à a IC ric = a;

double d = ric.f(5); // exécution de f de IC appliquée à a

Comment déterminer qu’une classe implémente une interface ?

Considérons une classe de base Base, une interface IB et une classe Der dérivée de Base et implémentant IB : class Base { ... } interface IB { ... }

class Der : Base, IB {

... }

(33)

Un objet de la classe Base est créé par :

Base b = new Base();

Un objet de la classe Der peut être créé par :

Der d1 = new Der();

mais aussi par (puisqu’un objet d’une classe dérivée est aussi un objet de sa classe de base) :

Base d2 = new Der(); // d2 est en fait un objet de la classe Der

Pour déterminer si un objet de la classe Base (b ou d2) implémente l’interface IB (c’est le cas pour d2 mais pas pour b), on peut écrire :

if (d2 is IB) ... // vrai

On peut donc écrire (le casting est nécessaire car d2 est officiellement un objet de la classe Base qui n’implémente pas IB) :

IB rib = (IB)d2;

rib.f(); // pas de problème puisque d2 est en fait un Der Mais que se passe-t-il si l’on écrit :

Base[] tabBase = new Base[2]; tabBase[0] = b;

tabBase[1] = d2;

foreach (Base bo in tabBase) {

rib = (IB)bo; rib.f(); }

Chaque cellule de tabBase est un objet Base ou un objet d’une classe dérivée de Base (Der en l’occurrence). Pour les cellules contiennent un objet Der, utiliser rib et appeler f via rib est correct. Pour les cellules contenant réellement un objet Base, cela constitue une erreur grave, avec terminaison du programme si l’erreur n’est pas interceptée dans un try/catch. Le compilateur doit accepter la syntaxe car la détermination du contenu réel de bo ne peut être effectué qu’au moment d’exécuter le programme.

Une première solution consiste à tester le contenu de bo :

foreach (Base bo in tabBase) if (bo is IB)

{

rib = (IB)bo; rib.f(); }

(34)

Une autre solution consiste à tenter une conversion (opérateur as) et à vérifier le résultat de la conversion :

foreach (Base bo in tabBase) {

rib = bo as IB;

if (rib != null) rib.f(); }

2.10 Les propriétés

Une propriété s’utilise comme un champ d’une classe mais provoque en fait l’appel d’une fonction. Une ou deux fonctions, appelées « accesseurs », peuvent être associées à une propriété : l’une, fonction getter, pour la lecture de la propriété et l’autre, fonction setter, pour l’initialisation ou la modification de la propriété. Si la fonction setter est absente, la propriété est en lecture seule.

Prenons un exemple pour illustrer les propriétés. Le prix renseigné d’un produit (propriété Prix) est multiplié par deux quand la quantité en stock est inférieure à dix unités. Nous commenterons le programme par la suite.

Les propriétés Ex7.cs

using System; class Produit {

public Produit(int q, int p) {m_Qtité = q; m_Prix = p;} public void Entrée(int qtité) {m_Qtité += qtité;} public void Sortie(int qtité) {m_Qtité -= qtité;} int m_Qtité;

int m_Prix;

public int Prix // Propriété Prix {

get {return m_Qtité<10 ? m_Prix*2 : m_Prix;} set {m_Prix = value;}

} }

class Prog {

static void Main() {

Produit p = new Produit(8, 100);

p.Entrée(15); p.Sortie(10); // Cinq produits en stock

Console.WriteLine("Prix unitaire actuel : " + p.Prix); }

(35)

Prix est une propriété de la classe Produit. Pour exécuter

n = p.Prix;

la fonction get de la propriété est automatiquement appelée et n prend la valeur renvoyée par cette fonction.

Pour exécuter :

p.Prix = 50;

la fonction set de la propriété est exécutée, avec value (mot réservé) qui prend automati-quement la valeur 50.

Rien n’empêche qu’une propriété soit qualifiée de static. Elle ne peut alors utiliser que des champs et des méthodes statiques de la classe, y compris le constructeur statique pour l’initialisation de la propriété. Une telle propriété peut être utilisée sans devoir créer un objet de la classe (préfixer le nom de la propriété du nom de la classe).

2.11 Les indexeurs

Un indexeur permet de considérer un objet comme un tableau. L’opérateur [] d’accès à un tableau peut alors être appliqué à l’objet. L’index, c-à-d la valeur entre crochets, peut être de n’importe quel type et plusieurs indexeurs peuvent être spécifiés (par exemple un index de type int et un autre de type string, comme dans l’exemple qui suit). On peut également trouver plusieurs valeurs (séparées par virgule) entre les crochets, ce qui permet de simuler des accès à des tableaux de plusieurs dimensions.

Pour implémenter un ou plusieurs indexeurs, il faut écrire une ou plusieurs fonctions qui s’appellent this et qui s’écrivent comme dans l’exemple suivant (notez les crochets pour les arguments de this). Nous créons une classe Polygame et les crochets donnent accès aux différentes épouses d’un polygame. L’argument peut être une chaîne de caractères. Dans ce cas, l’indexeur renvoie un numéro d’épouse ou –1 en cas d’erreur. Pour simplifier et nous concentrer sur les indexeurs, aucune vérification de dépassement de capacité de tableau n’est effectuée.

Dans la classe Polygame, nous maintenons un tableau de noms d’épouses. Celui-ci est créé et initialisé par la fonction Mariages. L’index sur le numéro est en lecture/écriture tandis que l’index sur le nom est en lecture seule. Pour simplifier, aucune vérification n’est effectuée sur les indices.

Références

Documents relatifs

Dans tous les cas, Segeta n'est pas ici représentée dans son temple, comme sur les monnaies de Salonine et comme d'autres divinités, comme Mercure, sur d'autres séries de jetons

politiques sont le RND (Rassemblement National Démocratique), Hamas (Mouvement de la Société Islamique), le RCD (Rassemblement pour la Culture et la Démocratie), le MDS (Mouvement

structure d’évaluation logique avec laquelle les physiothérapeutes sont déjà à l’aise de travailler. Suite aux recommandations des physiothérapeutes experts, la structure

Le concept inscription agenda agenda-setting désigne le processus par lequel certaines questions économiques et sociales deviennent des problèmes dans le débat public

Un noble tourangeau, certes d’une maison bien moins en vue que les Beaumont, entre comme eux en confl it avec les Plantagenêt pour son patronage architectural.. Tout avait

Un diagnostic moléculaire fondé sur l’analyse des mutations de PTEN ainsi que sur le niveau de phospho- rylation de protéines impliquées dans la voie PI 3-kinase/PTEN (p70-S6 kinase

Convergence d’un algorithme du gradient proximal stochastique à pas constant et généralisation aux opérateurs monotones aléatoires...

The control of regenerative vibration with a nonlinear energy sink variation was clearly presented by analytical and numerical simulation; machining chatter was