L’héritage

L’héritage permet de construire une classe B à partir d’une classe existante A. Il s’agit d’une sorte d’héritage biologique. La classe B va ainsi hériter des variables, des méthodes, bref de tous les membres de A puis, on va ajouter des éléments supplémentaires propres à la classe B.

  • Si B hérite de A, on dit que B est une classe fille, une sous-classe, une classe enfant ou encore une classe dérivée de A.

  • A l’inverse, A se nomme la classe mère, la classe de base, la classe parent ou encore la super classe de B.

On peut examiner l’héritage de deux façons différentes :

  • Dans la vision par spécialisation/redéfinition, la classe B est vue comme un cas particulier de la classe A. Par exemple, un 4x4 est un cas particulier d’une Voiture. Dans cette vision, l’ensemble des Voitures est l’ensemble englobant et les 4x4 représentent un sous-ensemble des Voitures.

  • Dans la vision par extension, la classe B est vue comme une version étendue de la classe A. Par exemple, un 4x4 est une voiture disposant de 2 roues motrices supplémentaires. Du point de vue des fonctionnalités, la classe 4x4 englobe les fonctionnalités de la classe Voiture, la classe Voiture a moins de paramètres internes que la classe 4x4.

../_images/demo.svg

Un héritage entre plusieurs classes donne naissance à une hiérarchie dans laquelle une classe peut avoir

  • des ancêtres : sa classe mère, la classe mère de sa classe mère, et ainsi de suite…

  • des descendants : ses classes filles, les classes filles de ses classes filles, et ainsi de suite…

L’héritage est une approche puissante pour la structuration des programmes et la réutilisation du code. Comme toute technique de réutilisation, elle facilite la maintenance et améliore la productivité. De plus, la structuration du programme induite par l’utilisation du mécanisme d’héritage facilite grandement la compréhension. Nous pouvons ainsi rencontrer une méthode Dessiner(…) recevant en paramètre un objet de type Crayon. En examinant la classe Crayon, nous remarquons que diverses classes en hérite : CrayonGras, CrayonFeutre, CrayonUni, CrayonDégradé, CrayonFluo… Intuitivement, on comprend que pour dessiner, on peut utiliser différents types de crayons et le tour est joué. Si nous n’avions pas utilisé un mécanisme d’héritage, nous aurions dû créer une méthode Dessiner pour chaque type d’objets Crayon. Si de plus, votre fonction Dessiner requiert un objet Pinceau pour colorier l’intérieur de l’objet, le nombre de fonctions à créer devient conséquent ! Pour une dizaine de crayons et de pinceaux différents, cela donne une centaine de fonctions Dessiner(Crayon,Pinceau,…) à créer pour couvrir chaque scénario. Beaucoup de travail inutile en vue et une documentation illisible en perspective !

La documentation MSDN permet de voir la hiérarchie de classe pour chaque classe du framework .net : par exemple pour la classe System.Windows.Shapes.Rectangle on obtient :

../_images/heritageRectangleMSDN.png

Ce qui signifie que la classe Rectangle hérite de Shape, qui elle-même hérite de FrameworkElement et ainsi de suite.

Important

On dit souvent que la relation d’héritage doit être compatible avec la relation … est une sorte de …. Cela signifie que si la classe B hérite de la classe A alors je dois pouvoir dire que B est une sorte de A. Par exemple un Lapin est une sorte d’Animal, un 4x4 est une sorte de Voiture. Par contre je ne peux pas dire qu’une Voiture est une sorte de Roue: il n’est donc pas possible que Voiture hérite de Roue. L’inverse n’est par contre pas vrai: ce n’est pas par ce que B est une sorte de A que B doit hériter de A.

Principe

L’héritage est déclaré lors de la déclaration de la classe avec la notation :.

Syntaxe

class IdentificateurClasse : IdentificateurClasseParent
{

}

Exemple:

class Vehicule
{
        public string nom;
        public void AfficheNom(){
                WriteLine(nom);
        }
}

class Voiture : Vehicule
{
        public int puissance;
        public void Info() {
                AfficheNom();  // on hérite des méthodes de Vehicule
                WriteLine(puissance);  // on hérite des champs de Vehicule
        }
}


Vehicule vehicule = new Vehicule();
vehicule.nom = "Proto2";
vehicule.AfficheNom();  // Proto2
// vehicule.puissance = 4; ERREUR
// vehicule.Info();     ERREUR

Voiture voiture = new Voiture();
voiture.nom = "Buggy";
voiture.puissance = 4;
voiture.Info(); // Buggy 4
voiture.AfficheNom(); // Buggy

Règles :

  • Une classe ne peut avoir qu’une seule classe parent. L’héritage multiple est interdit.

  • Les cycles sont interdits : A ne peut hériter de B si B est un descendant de A.

Considérons les 2 classes A et B ci dessous.

class A {
        public void MethodeA(){};
}
class B : A {
        public void MethodeB(){};
}

Un aspect fondamental de l’héritage est que si une classe B hérite d’une classe A, alors les objets de type B peuvent aussi être vus comme des objets de type A. On dit que le type A englobe/est plus grand que le type B, ce qui peut se représenter par un diagramme de Venn:

../_images/diagrammepatate.svg

Si la classe B hérite de la classe A alors, du point de vue du typage, les objets de type B forment un sous ensemble des objets de type A.

Je peux créer des variables et des objets de type A et B, et comme les objets de type B peuvent également être vus comme étant du type parent A, je peux les référencer par des variables de type A:

A a1 = new A();
B b1 = new B();
A a2 = new B(); // a2 est de type A mais référence un objet de type B
A a3 = b1;      // idem pour a3

On est alors dans la situation où la variable a2 a pour type déclaré A (le type de la variable tel qu’on l’a écrit dans le code) et pour type constaté B (le type de l’objet qui est référencé par la variable au moment de l’exécution).

../_images/typeDeclConst.svg

Lorsqu’on utilise l’opérateur d’accès ., c’est le type déclaré qui est utilisé par le compilateur pour déterminer si on accède à un membre valide:

b1.MethodeA(); // OK car b1 est de type déclaré B qui hérite de A
b1.MethodeB(); // OK car b1 est de type déclaré B
a2.MethodeA(); // OK car a2 est de type déclaré A
// a2.MethodeB(); ERREUR de compilation  car a2 est de type déclaré A (même si le type constaté est B)

Lorsque qu’une classe B hérite d’une classe A, B n’a pas accès aux membres privés de A. Afin d’avoir un contrôle plus fin sur la visibilité, l’héritage introduit le nouveau niveau de visibilité protected qui ouvre l’accès aux enfants mais pas à l’extérieur. On se retouve ainsi avec 3 niveaux de visibilités possibles pour les membres de la classe A:

  • public : accès ouvert pour l’extérieur et pour les classes dérivées

  • protected : accès fermé pour l’extérieur mais ouvert pour les classes dérivées

  • private : accès fermé pour l’extérieur et fermé pour les classes dérivées

Note

Comment faire lorsqu’une classe semble hériter de plusieurs autres classes. Par exemple : un smartphone est à la fois un téléphone et un lecteur MP3. Une première solution serait de créer une fusion des deux classes Téléphone et Lecteur MP3. On parle alors d’héritage multiple. Ce choix fut longtemps plébiscité par le langage C++. Ainsi, la nouvelle classe rassemble tous les membres des classes parentes. Une contrainte apparaît au niveau des conflits entre les membres de même nom. Il faudra alors les discerner explicitement. Tous les membres des classes parents sont conservés. Un problème apparaît réellement si des membres ont le même rôle. Ils deviennent alors redondants ce qui peut être source de confusion : par exemple, un téléphone et un lecteur MP3 possèdent respectivement une batterie, un objet SmartPhone possède-t-il deux batteries ? Si l’on veut conserver une seule batterie, laquelle faut-il conserver ?

L’héritage multiple peut poser des problèmes délicats, il y a donc controverse sur le fait de savoir si ses avantages surpassent ses inconvénients. Pour cette raison, il a été retiré des langages modernes (Java, C#). Face à cette situation, on peut essayer d’utiliser une agrégation.

Pour chacune de ces affirmations, indiquez si elle est vraie ou fausse:

  1. Une classe peut avoir plusieurs classes mères.

  2. Une classe peut avoir plusieurs ancêtres.

  3. Une classe peut avoir plusieurs classes filles.

  4. Une classe peut avoir plusieurs descendants.

  5. Plusieurs classes peuvent hériter d’une même classe.

class A {
        public int attributPublicA;
        protected int attributProtectedA;
        private int attributPrivateA;
}
class B : A {}

class C {}

Pour chacune de ces affirmations, indiquez si elle est vraie ou fausse:

  1. La classe B hérite de attributPublicA

  2. Dans la classe B on peut accéder à attributPublicA depuis l’instance courante.

  3. La classe B hérite de attributProtectedA

  4. Dans la classe B on peut accéder à attributProtectedA depuis l’instance courante.

  5. La classe B hérite de attributPrivateA

  6. Dans la classe B on peut accéder à attributPrivateA depuis l’instance courante.

  7. Dans la classe C on peut accéder à attributPublicA depuis un référence sur un objet de type A.

  8. Dans la classe C on peut accéder à attributProtectedA depuis un référence sur un objet de type A.

  9. Dans la classe C on peut accéder à attributPrivateA depuis un référence sur un objet de type A.

  10. Dans la classe C on peut accéder à attributPublicA depuis un référence sur un objet de type B.

  11. Dans la classe C on peut accéder à attributProtectedA depuis un référence sur un objet de type B.

  12. Dans la classe C on peut accéder à attributPrivateA depuis un référence sur un objet de type B.

class Mere{
        public int attributMere;
        public void methodeMere(){};
}

class Fille : Mere {
        public int attributFille;
        public void methodeFille(){};
}

...
Mere a = new Mere();
Fille b = new Fille();
Mere c = new Fille();

Pour chacune des 3 variables a, b et c, donnez son type déclaré et son type constaté, puis dites si il est possible d’accéder aux membres des classes Mere et Fille avec l’opérateur . à partir de cette variable:

Variable

a

b

c

Type déclaré

Type constaté

attributMere

MethodeMere

attributFille

MethodeFille

Fonctions Polymorphes

Le terme polymorphisme désigne la capacité d’une méthode à pouvoir prendre un comportement différent suivant le contexte: types des paramètres et type de l’objet courant.

Par exemple, si l’on veut créer une fonction calculant le maximum de deux nombres, il serait pratique qu’elle puisse porter le même nom quels que soient les paramètres utilisés. On voudrait créer ces fonctions de la manière suivante :

int     Max(int a, int b)               { ... }
float   Max(float a, float b)           { ... }
double  Max(double a, double b)         { ... }
...
k = Max(u,v) ;

Le compilateur connaît les types des variables u et v, il va donc chercher la fonction Max prenant ces types là en arguments. Les différentes fonctions Max ne traitent pas les mêmes éléments, pourtant conceptuellement, elles effectuent le même calcul. C’est un des aspects du polymorphisme, on parle dans ce cas de polymorphisme ad hoc.

Ce concept de polymorphisme s’étend à l’héritage. Par exemple si vous faîtes un jeu de simulation affichant plusieurs types de véhicules à l’écran, il serait agréable d’avoir une unique fonction AfficheToi(). Ainsi pour dessiner l’écran du jeu, je demande à chaque objet de s’afficher en utilisant le même nom de fonction. Cette action est similaire pour une voiture, un avion, un vélo, chacun va s’afficher à l’écran, mais avec un comportement différent. Un objet A320 dessinera spécifiquement un Airbus A320, chaque voiture Peugeot 205 dessinera une Peugeot 205… Il est possible, mais plus lourd à gérer, de créer des fonctions différentes comme AfficheA320(), AfficheC3(), AffichePeugeot205()…

Ainsi une fonction peut exister sous le même nom dans une hiérarchie mais avoir un comportement différent suivant le type de l’objet instancié. On parle alors de polymorphisme d’héritage.

Polymorphisme ad hoc

Le polymorphisme ad hoc (ou surcharge, ou overload) consiste à définir plusieurs fonctions avec le même nom au sein de la même classe.

On appelle signature d’une fonction le n-uplet formé de son nom et de l’ensemble des types de ses arguments (l’ordre est important). Par exemple, la méthode

double Foo(int a, string b, float [] c){}

a pour signature (foo, int, string, float []).

Le C# autorise la définition d’une méthode si sa signature est différente de celles des autres méthodes de la classe. Il est important de noter que le type de retour ne fait pas parti de la signature, on ne peut donc pas avoir deux méthodes portant le même nom, prenant le même type d’argument mais avec un type de retour différent.

Exemples:

int F(int a, double b) {} // définition de f avec 2 arguments int et double
int F() {} // redéfinition de f sans argument
double F(double a) {} // redéfinition de f avec un argument double
void F(double a, int b) {} // redéfinition de f avec 2 arguments double et int
// double F() {} ERREUR : la signature de cette fonction est la même que "int f()"

Lors de l’appel à la fonction F polymorphe, le compilateur détermine quelle version doit être utilisée en suivant son algorithme de résolution de nom.

F(1, 3.0);      // 1ere definition utilisée, signature (F, int, double)
F();            // 2e definition utilisée, signature (F)
F(2.0);         // 3e definition utilisée, signature (F, double)
F(3.0, 1);      // 4e definition utilisée, signature (F, double, int)

Si aucune signature ne correspond exactement aux arguments passés à la fonction le compilateur essaye de trouver la solution la plus proche à partir des règles de conversion implicites autorisées.

F(2); // appel équivalent à F(2.0) par promotion de 2 en double

Si le compilateur ne peut pas trouver de version de F correspondant à l’appel ou si la version à utiliser est ambigüe il indique une erreur.

F("toto"); // ERREUR : aucune définition ne correspond
F(1,1); // ERREUR : le compilateur ne sait pas si il faut utiliser F(int,double) ou F(double,int)
F((double)1,1); // on peut lever l'ambigüité avec un cast explicite.

Dans une classe qui possède déjà une unique méthode F ont peut re-déclarer la fonction F en modifiant uniquement:

  1. Le nombre de paramètres dans la nouvelle définition.

  2. Le type de retour dans la nouvelle définition.

  3. Les noms de paramètres dans la nouvelle définition.

  4. Le type des paramètres dans la nouvelle définition.

La classe A possède une unique méthode statique public static bool F(String a, bool b, double c, int d){}, quelles sont les instructions correctes ?

  1. A.F("" + new A(), true, 2.0, 1);

  2. A.F("", false, 2.0f, 1);

  3. A.F("F", 1>2, 2, 0);

  4. A.F("" + 1.0, "foo".Equals("bar"), 2 , 1);

  5. A.F("FF", A.F("", true, 1.0, 1), 2.0, 1);

Soit le programme suivant:

1class A{
2        public static void F(){} // 1ère
3        public static int F(int i){return i;} // 2ème
4        public static double F(double i, double j){return i;} // 3ème
5        public static double F(int i, double j){return j;} // 4ème
6        public static double F(double i, int j){return i;}  // 5ème
7        public static void F(string b){} // 6ème
8        public static void F(string b, string a){} // 7ème
9}

Pour chaque chaque appel de fonction, indiquez quelle définition de F est utilisée ou « Erreur » si l’appel n’est pas valide.

Appel de fonction

Définition utilisée

Appel de fonction

Définition utilisée

F(1)

F("2", 1)

F(2.0)

F("", 2 + ".0")

F("" + 2.0)

F(true)

F(1, 2)

F("" + false)

F(1.0, 2)

F()

F(1.0, 2.0)

Polymorphisme d’héritage

Le polymorphisme d’héritage va consister à redéclarer dans une classe fille, une méthode déjà déclarée dans une classe parent.

Contrairement au polymorphisme ad hoc qui n’utilise pas de mot clé particulier, on utilisera ici le couple virtual/override. La première déclaration de la méthode doit porter le qualificatif virtual et toutes les classes enfants modifiant son comportement doivent précéder sa déclaration par le qualificatif override.

class A {
        public virtual void Afficher(){
                WriteLine("Bonjour");
        }
}

class B : A
{ }

class C : B {
        public override void Afficher() {
                WriteLine("Coucou");
        }
}

class D : C
{
        public override void Afficher(){
                WriteLine("Hello");
        }
}

A obj1 = new A();
C obj2 = new C();

obj1.Afficher();                // résultat => Bonjour
obj2.Afficher();                // résultat => Coucou

Jusqu’ici, tout va bien. Il est facile de lever l’ambiguïté de l’appel de la méthode Afficher car le type de l’objet est connu lors de la création de obj1 et obj2: les types déclarés et constatés sont identiques.

Cependant, pour créer une liste d’objets cela devient plus difficile. Pour fonctionner, la liste stocke des références vers les objets concernés. En créant cette liste, la syntaxe va imposer de choisir un type unique pour toutes les références stockées. Par bon sens, nous choisissons comme type celui de l’ancêtre de la hiérarchie :

A[] Liste = new A[4] ;
Liste[0] = new A();
Liste[1] = new B();
Liste[2] = new C();
Liste[3] = new D();

On peut alors parcourir la liste et appeler la méthode Afficher sur chacun de ses éléments:

foreach(A m in Liste)
        m.Afficher();

// Résultats : Bonjour Bonjour Coucou Hello

Dans ce contexte, les éléments de la liste sont de type A. Néanmoins lors de l’exécution, le CLR va regarder le type constaté de chaque élément pour déterminer quelle méthode Afficher doit être exécutée.

Note

Quand on utilise une méthode qualifiée avec virtual/override, le CLR doit donc décider, à chaque fois que la méthode est appellée, quelle version doit être exécutée selon le type constaté de l’objet. Cette décision ne peut pas être prise au moment de la compilation du programme (seul le type déclaré est connu à ce moment) et a donc un coût lors de l’exécution du programme. Ce coût est généralement négligeable mais peut devenir important lorsque la méthode est appellée très fréquemment. Pour en savoir plus, vous pouvez regarder comment sont généralement implémentées les méthodes virtuelles avec les vtables

Attention

Si l’on oublie les mots clés virtual/override, cela ne provoquera pas d’erreur (ni à la compilation, ni à l’exécution) mais le comportement ne sera pas celui souhaité:

class A {
        public void Afficher(){
                WriteLine("Bonjour");
        }
}

class B : A
{ }

class C : B {
        public void Afficher()  {
                WriteLine("Coucou");
        }
}

class D : C
{
        public void Afficher(){
                WriteLine("Hello");
        }
}

A[] Liste = new A[4];
Liste[0] = new A();
Liste[1] = new B();
Liste[2] = new C();
Liste[3] = new D();

foreach(A m in Liste)
        m.Afficher();   // Résultats : Bonjour Bonjour Bonjour Bonjour

Cela pose problème car les objets sont gérés comme s’ils étaient TOUS de type A : c’est uniquement le type déclaré qui est pris en compte. En l’absence de toute syntaxe particulière, le langage considère que le type de l’objet référencé est donné par le type de la référence. On parle de masquage.

class A
{
        public double F1 ( double v ) { return v-1 ; }
        public int F1 ( int v ) { return v+1; }
        public virtual int F2 ( int v ) { return v+2 ; }
        public virtual int F3 ( int v ) { return v+3 ; }
}

class B : A
{
        public override int F2 ( int v ) { return v+4 ; }
}

class C : B
{
        public int F1 ( int v )          { return v+5 ; }
        public override int F2 ( int v ) { return v+6 ; }
        public override int F3 ( int v ) { return v+7 ; }
}

Pour chaque prototype de fonction:

  • Indiquez s’il s’agit de polymorphisme d’héritage : Oui/Non

  • Indiquez ce qu’il se passe dans chaque classe pour cette fonction : Définition, Héritage (de qui ?), Masquage (pas d’héritage) ou Polymorphisme

Prototype

Polymorphisme d’heritage

Dans la classe A

Dans la classe B

Dans la classe C

double F1(double)

int F1(int)

int F2(int)

int F3(int)

Soient les classes A, B et C décrites dans l’exercice ci-dessus et le programme suivant:

class Program
{
        public static void Main(string[] args)
        {
                A aa = new A() ;        A ab = new B();         A ac = new C() ;
                B bb = new B() ;        B bc = new C();         C cc = new C() ;
                ...
        }
}

Donnez la valeur retour des appels de fonction suivants :

Appel

Résultat

Appel

Résultat

Appel

Résultat

aa.F1(0)

ab.F3(0)

bb.F3(0)

ab.F1(0.0)

ac.F3(0)

bc.F3(0)

ac.F1(0)

bb.F1(0)

cc.F1(0)

aa.F2(0)

bc.F1(0.0)

cc.F1(0.0)

ab.F2(0)

bb.F2(0)

cc.F2(0)

ac.F2(0)

bc.F2(0)

cc.F3(0)

aa.F3(0)

Constructeurs et chaînage

Prenons une classe B enfant d’une classe A. Lors de l’écriture des constructeurs de la classe B, on doit se poser la question de l’initialisation des éléments provenant de A. Pour cela nous allons chainer les constructeurs de la classe B avec les constructeurs de la classe A : contrairement au chainage d’un constructeur d’une même classe qui est optionnel, le chainage des constructeurs avec les constructeurs de la classe mère est une obligation.

Le chainage d’un constructeur avec un constructeur de la classe mère utilise une syntaxe similaire au chainage dans la même classe mais avec le mot clé base:

Syntaxe

class A{
        public A(){}:
}

class B : A{
        public B() : base() // chainage avec le constructeur sans argument de A
        {}
}

Lors de la construction de l’objet de type B, le fait que le constructeur de A soit appelé ne veut pas dire qu’un objet de type A soit créé ! Il s’agit uniquement de l’initialisation des membres hérités de A au travers du constructeur de A. Au final, il n’y a qu’un seul objet de type B créé.

Note

Les constructeurs d’une même classe peuvent toujours être chainés entre eux mais chaque chaine doit se terminer par un constructeur chainé sur un constructeur de la classe mère.

Lorsque l’on construit un objet d’une classe fille, le constructeur chainé (donc celui de la classe mère) est exécuté en premier:

class A
{
    public A()
        { WriteLine("construction de A"); }
}

class B : A
{
    public B() : base()
        { WriteLine("construction de B"); }
}

B toto = new B() ;      //  construction de A  construction de B

Le constructeur de la classe parent est lancé AVANT les instructions du constructeur de la classe B. Si la classe A possède un parent, son constructeur sera appelé avant que celui de A soit exécuté et ainsi de suite. Au final, c’est le constructeur du parent initial dans la hiérarchie qui sera le premier lancé, puis ceux de ses enfants successivement. Ce comportement logique provient du fait que pour fonctionner, la classe B peut nécessiter des ressources venant de sa classe mère, le parent doit donc être créé avant.

Important

Si vous ne chaînez pas explicitement un constructeur de B, le compilateur génèrera automatiquement un chainage implicite avec le constructeur sans argument de la classe parent. Si la classe parent ne possède pas de constructeur sans argument cela génèrera une erreur de compilation.

Pour chacune de ces affirmations, indiquez si elle est vraie ou fausse:

  1. Dans une classe fille, je dois déclarer un constructeur.

  2. Le chainage avec un constructeur de la classe mère se fait avec le mot clé this.

  3. Dans une classe fille, un constructeur doit initialiser lui-même les attributs de la classe mère.

  4. Dans une classe fille, un constructeur doit initialiser lui-même les attributs de toutes les classes ancêtres.

  5. Si je ne chaine pas un constructeur vers un constucteur de la classe mère alors les attributs hérités seront initialisés par défaut.

  6. Dans une classe fille, je ne peux pas chainer un constructeur vers un constructeur de la même classe.

  7. Dans une classe fille, si je ne déclare pas de constructeur, le compilateur en ajoutera un, ainsi il ne peut pas y avoir de problème.

  8. Un constructeur de la classe mère est toujours exécuté avant l’exécution d’un constructeur de la classe fille.

Chaînage des méthodes

Il est toujours possible d’appeler la fonction qui a été redéfinie à partir de la redéfinition. Pour cela on utilise le mot clé base. Cela permet de construire des chaînages de fonctions dans le même principe que le chaînage des constructeurs. Par exemple, supposons que la classe Voiture dispose d’une fonction TestAllumage() qui vérifie que tous les composants électriques (ordinateur de bord, ABS, ESP, air bag, …) de la voiture fonctionnent bien. Dans le cas d’un Cabriolet qui ajoute un toit amovible, il faudra contrôler l’ensemble des systèmes d’une voiture simple et le système qui rentre/sort le toit amovible.

class Cabriolet : Voiture
{
        public override void TestAllumage()
        {
                base.TestAllumage();  // test des éléments de la classe Voiture
                                      // par appel a TestAllumage() de Voiture
                TestToitAmovible();   // test des éléments spécifiques au Cabriolet
        }
        ...
}

Pour chacune de ces affirmations, indiquez si elle est vraie ou fausse:

  1. Par défaut une méthode override n’est pas chainée avec la méthode qu’elle redéfinit.

  2. Si je chaine une méthode override avec la méthode redéfinie, cela doit être la première instruction de la méthode.

  3. Je peux chainer une méthode avec une méthode privée de la classe mère.

  4. Le chainage est effectué avec le mot clé base et l’opérateur ..

Classes et méthodes abstraites

Considérons un logiciel de dessin vectoriel. Ce logiciel de dessin doit être capable de manipuler différents objets : des ellipses, des rectangles… Il va donc être util de les regrouper dans une même hiérarchie avec un parent commun : ObjetVectoriel. Il sera naturel d’utiliser une méthode polymorphe Draw() pour ces objets. Cependant la classe ObjetVectoriel ne contient pas d’information géométrique et elle ne sait pas se dessiner : on ne sait pas quoi écrire dans la méthode Draw de la classe ObjetVectoriel.

Lorsque l’on a identifié un cas tel que la classe ObjectVectoriel qui doit posséder une méthode dont on ne peut déterminer le contenu mais qui devra être définie dans les classes filles, il s’agit d’une classe abstraite et d’une méthode abstraite. On ajoute alors le qualificatif absract à la classe et à la méthode:

abstract class Shape
{
        protected string nom ;
        public abstract void Draw() ;   // méthode abstraite : pas de block {}
}

class Triangle : Shape
{
        protected Point p1, p2, p3 ;
        public override void Draw()     { ... } // (re)définition de la méthode Draw
}

On remarque qu’une méthode abstraite est automatiquement virtuelle. Par contre, il faut utiliser le qualificatif override lors de sa définition dans les sous-classes.

Une classe abstraite ne peut pas être instanciée (elle est incomplète car les méthodes abstraites n’ont pas de corps), ce qui ne l’empêche pas de posséder un ou plusieurs constructeurs appelés par le mécanisme de chaînage des constructeurs.

Important

Une classe non abstraite fille d’une classe abstraite doit implémenter toutes les méthodes abstraites de sa classe mère.

Pour chacune de ces affirmations, indiquez si elle est vraie ou fausse:

  1. Une classe abstraite ne peut pas être instanciée.

  2. Une méthode abstraite ne possède pas de corps.

  3. Une classe non abstraite peut posséder des méthodes abstraites.

  4. Une classe fille n’hérite pas des méthodes abstraites de sa classe mère.

  5. Une classe fille non abstraite doit redéfinir toutes les méthodes abstraites dont elle hérite.

  6. Une méthode dont le corps ne contient pas d’instruction est abstraite.

  7. Une méthode abstraite peut avoir un type de retour différent de void.

Une classe mère commune

En C#, toute classe sans parent explicite hérite implicitement de la classe mère Object. La class Object est ainsi la racine de la hiérarchie de toutes les classes, les vôtres comme celles de .net. La classe Object déclare cinq méthodes d’instance accessibles à partir de tous les objets.

Méthode

Description

public virtual string ToString()

Retourne une chaîne de caratères qui représente l’objet

public virtual bool Equals(Object)

Détermine si l’objet spécifié est égal à l’objet en cours

public virtual void Finalize()

Fonction exécutée par le ramasse miette avant la destruction de l’objet

public virtual int GetHashCode()

Retourne le code de hashage de l’objet

public Type GetType()

Retourne le type de l’objet

public Object MemberWiseClone()

Créé une copie superficielle de l’objet

Note

La méthode ToString est automatiquement invoquée lors de la conversion implicite d’un objet en chaine de caractères. C’est une pratique courante de redéfinir cette méthode pour faciliter les affichages.

class A{
        public override String ToString(){
                return "Objet de la classe A";
        }
}

...

A a = new A();

WriteLine(a); // équivalent à WriteLine(a.ToString());
String s = "a est un " + a;  // équivalent à String s = "a est un " + a.ToString();

Exercices

Les qualificatifs de visibilité (public, protected et private) sont là pour gérer l’encapsulation. Il est souvent difficile de déterminer le qualificatif de visibilité idéal pour les membres d’une classe. Néanmoins, on peut tout de même donner quelques règles générales :

  • Si une méthode correspond à un des services que rend la classe, elle doit pouvoir être appelée de l’extérieur et doit être qualifiée de public.

  • Si une méthode ne doit pas être accessible de l’extérieur, il faut il faut choisir entre le qualificatif protected et private. Ici, le choix devient plus complexe car il faut pouvoir se projeter dans le futur : les classes filles de notre classe doivent-elles pouvoir accéder à ce membre ? Si les enfants doivent avoir accès, on choisit protected. Si l’on reste fermé des enfants et de l’extérieur, ce sera private.

Pour les champs, si un champ contient une information qui doit être accessible depuis l’extérieur de la classe alors il vaut mieux le qualifier de private et définir une propriété publique associée. Cette propriété sera publique et permettra de contrôler de manière transparente l’accès à ce champ pour vérifier par exemple la validité des valeurs passées en paramètres.

Considérons l’exemple suivant composé de deux classes. La classe StandardBankAccount représente le compte bancaire d’un client lambda n’ayant pas le droit de dépenser plus qu’il ne possède (pas de solde négatif). La classe PremiumBankAccount qui hérite de StandardBankAccount représente le compte bancaire d’un client ayant le droit d’avoir un solde négatif (avec une certaine limite).

class StandardBankAccount
{
        string owner; // propriétaire du compte
        double balance; // solde du compte

        StandardBankAccount(string owner, double balance)
        {
                this.owner = owner ;
                this.balance = Math.max(0, balance) ;
        }
        boolean Deposit( double amount )
        {
                bool flag = false ;
                if ( amount > 0 )
                   { balance += amount ; flag = true ; }
                return flag;
         }
        boolean Withdraw( double amount )
        {
                bool flag = false ;
                if ( checkPermission( amount ) )
                        { balance -= amount ; flag = true ; }
                 return flag;
         }

         virtual bool CheckPermission( double withdrawAmount )
        { return withdrawAmount > 0 && balance - withdrawAmount >= 0 ; }
}


class PremiumBankAccount : StandardBankAccount
{
        double allowedDebt; // encours négatif permis

        PremiumBankAccount( string owner, double amount, double allowedDebt )
        : base( owner, amount )
        {  this.allowedDebt = Math.max( 0, allowedDebt ) ;  }

        override bool CheckPermission( double withdrawAmount )
        { return withdrawAmount > 0 && balance - withdrawAmount >= -allowedDebt ; }
}

Dites pour chacun des membres de la classe StandardBankAccount quel qualificatif de visibilité est le plus adapté. Dans le cas des champs, dites en plus s’il vaudrait mieux les remplacer par une propriété en réfléchissant à la visibilité des accesseurs.

Membre

Mode d’encapsulation

owner

balance

StandardBankAccount

Deposit

Withdraw

CheckPermission

Pour sa nouvelle édition, notre grande course de lapins a décidé de mettre un peu de piquant dans la compétition en invitant des tortues et la course devient, de fait, la grande course des animaux.

Dans notre nouveau modèle, les lapins possèdent les caractéristiques suivantes : surnom, age, couleur de la fourrure et position (un nombre entier qui mesure la distance depuis la ligne de départ). Les tortues possèdent quant à elle les caractéristiques suivantes : surnom, age, épaisseur de la carapace et position. Les deux animaux possèdent les méthodes Avancer et ToString (représenter l’animal sous forme de chaine de caractères). On constate immédiatement qu’une tortue et un lapin sont des sortes d’animaux qui possèdent des éléments en commun, nous allons donc créer une classe Animal qui regroupe tout ce qui est commun. Nous pourrons alors écrire deux classes : Lapin et Tortue qui héritent de Animal. Chacune de ces classes ajoutera les caractéristiques propres à l’animal et redéfinira les méthodes de manières adéquates.

Dans un nouveau projet Console sous Visual Studio:

  1. Écrivez la classe Animal contenant tous les éléments commun aux lapins et aux tortues. Écrivez un constructeur paramétrique permettant de facilement initialiser les propriétés. La méthode sting ToString() retourne une chaîne de caractère contenant les informations sur l’animal : « surnom, age, position ». La méthode void Avancer() est connue des lapins et des tortues mais l’action à réaliser n’est pas déterminée pour la classe Animal que faut-il faire ?

  2. Écrivez les classes Lapin et Tortue qui héritent de Animal. Ajoutez les champs nécessaires et définissez des constructeurs paramétriques pour les initialiser (pensez aux contraintes de chaînage des constructeurs).

  3. Redéfinissez la méthode ToString dans les classes Lapin et Tortue afin d’ajouter les informations spécifiques à la description de l’animal en question. Par exemple, l’appel de ToString sur un objet de type Tortue renvoie une chaîne de la forme « surnom, age, position, épaisseur de la carapace ».

  4. Redéfinissez la méthode Avancer dans les classes Lapin et Tortue. Dans le cas des lapins la méthode Avancer fait avancer le lapin d’une distance aléatoire entre 0 et 10. La tortue avance toujours de 6 lorsque l’on appelle la méthode Avancer. La méthode d’instance int Next(int maxValue) de la classe Random génère un nombre (pseudo) aléatoire compris entre 0 (inclus) et maxValue (exclus).

  5. Réalisez les actions suivantes dans la méthode Main : Créez deux lapins et deux tortues et les ranger dans un tableau d’animaux. Faites avancer chacun des animaux 100 fois. Déterminez quel animal a remporté la course et affichez ses caractéristiques.