Héritage simple

Chapitres traités   

Dans le chapitre précédent, nous avons évoqués les principes généraux de l'héritage sans tenir compte d'un langage quelconque. Ce chapitre sera donc consacré à l'étude de l'héritage simple codé avec le langage C++.


Choix du chapitre Déclaration de l'héritage simple (classes sans constructeurs)

Le diagramme ci-contre nous montre un exemple simple d'héritage. Il n'est, certes, pas très sophistiqué, mais il va me servir d'appui pour bien spécifier les différentes écritures.

La déclaration et la définition de la classe Elève est classique, c'est-à-dire, que l'écriture est comme une classe normale.

Seule, la signature de la classe est particulière, puisqu'il s'agit d'indiquer que la classe Elève hérite de la classe  Personne.

Pour indiquer une dérivation, il faut utiliser l'opérateur «  :  » (déjà utilisé dans les listes d'initialisation, ici il s'agit d'une liste de dérivation) suivit de nom de la classe de base. Pour un héritage classique, il est nécessaire de faire une dérivation publique.


Choix du chapitre Utilisation de tous les membres de la classe dérivée

Lorsqu'une classe dérivée hérite d'une classe de base, elle s'approprie de tout le comportement de la classe de base. La classe dérivée récupère donc l'ensemble des attributs et des méthodes de la classe de base. C'est comme si nous avions une seule classe avec une fusion de tous les attributs et de toutes les méthodes.


Choix du chapitre Contrôle des accès

Effectivement, l'enfant récupère tout ce que possède le parent. Mais attention, il existe quand même un petit problème d'accessibilité. Bien que  Elève soit aussi une Personne avec un nom et un prénom, malgré tout, ces deux attributs ne sont pas accessibles directement puisqu'ils sont déclarés privés dans la classe de base.

Les statuts des membres d'une classe

  1. privé : Le membre (généralement l'attribut) n'est accessible qu'aux méthodes de la classe (publiques ou privées). De l'extérieur, il n'est pas possible d'atteindre ce membre. Même sa descendance ne peut pas y accéder directement. Seule l'amitié donne des droits d'accès privilégiés pour atteindre les attributs privés.
  2. public  : le membre est cette fois-ci accessible non seulement aux méthodes de la classe et aux fonctions amies, mais également à l'utilisateur de la classe, les enfants compris.

Ainsi, pour en revenir à notre scénario, un élève ne peut pas savoir le nom et le prénom directement. Heureusement, il existe des méthodes de lecture qui ont été mis en œuvre dans la classe de base pour connaître indirectement son propre nom – getNom() - et son propre prénom – getPrenom().

Il existe une règle de conception de hiérarchie qui stipule que la classe dérivée ne doit pas avoir besoin de connaître les détails de l'implémentation de la classe de base. Je pense qu'il est bon d'avoir en tête cette démarche. Elle correspond à un principe de sécurité maximale. Pour notre exemple, cela ne pose pas de problème puisqu'il existe des méthodes pour atteindre tous les attributs privés de la classe de base.

La classe de base ne possède pas toujours des méthodes de lecture pour atteindre les attributs. Il serait alors souhaitable d'être moins rigoureux et de permettre uniquement à la descendance (l'enfant ou l'enfant de l'enfant) l'accès à tous les attributs (ou éventuellement une partie) de la classe parente. Il existe un nouveau statut qui propose cette autorisation particulière. Il s'agit du statut protected .

Les statuts des membres d'une classe

  1. protected : Les membres protégés se présentent comme des membres privés pour l'utilisateur de la classe de base, mais ils sont comparables à des membres publics pour la classe dérivée et pour toute la descendance. Pour l'utilisateur des classes dérivées, les membres protégés continuent à être considérés comme des membres privés.

Attributs privés

Attributs protégés

Accès des attributs dans l'héritage

Nous pensons souvent que pour avoir le maximum de souplesse, il suffit de déclarer protégés tous les attributs de la classe de base. Ainsi les enfants dans toute la hiérarchie peuvent y accéder sans aucune restriction. Toutefois cela peut être dangereux puisque un développeur par héritage peut modifier le comportement prévu par la classe de base.

Il ne faut pas que les attributs de la classe de base soient systématiquement protégés. Je pense que par réflexe, il faut d'abord les considérer comme privés, parce qu'il n'est pas toujours nécessaire que les enfants puissent accéder directement à tout ce que font les parents.

Choix du chapitre Dérivation publique, protégée, et privée

Dérivation publique

Les membres hérités d'une classe de base publique conservent leurs niveaux d'accès à l'intérieur de la classe dérivée. C'est le comportement normal prévu, par exemple, lors de la conception d'une application en UML. En général dans une hiérarchie de dérivation publique, chaque classe ultérieurement dérivée a accès à la série combinée des membres protégés et publics des classes de base précédentes le long de cette branche particulière de la hiérarchie.

Rappelons :

  1. Les membres publics de la classe de base sont accessibles « à tout le monde », c'est-à-dire à la fois aux méthodes, aux fonctions amies de la classe dérivée ainsi qu'aux utilisateurs de la classe dérivée.
  2. Les membres protégés de la classe de base sont accessibles aux méthodes et aux fonctions amies de la classe dérivée, mais pas aux utilisateurs de cette classe dérivée.
  3. Les membres privés de la classe de base sont inaccessibles à la fois aux méthodes ou aux fonctions amies de la classe dérivée et aux utilisateurs de la classe dérivée.

Dérivation privée

Les membres hérités publics et protégés d'une classe de base  privée deviennent des membres privés de la classe dérivée.

Cette technique permet d'interdire, aux utilisateurs d'une classe dérivée, l'accès aux membres publics de sa classe de base. Cela sous-entend que seules les méthodes de la classe dérivée devront être utiliser, sinon il faudra redéfinir les méthodes de la classe de base (voir plus loin). Pour les dérivations à partir de la classe dérivée, l'accès à la classe de base devient alors totalement inaccessible, les petits enfants n'ont donc pas accès aux membres de leur grand parent.


Dérivation protégée

Les membres hérités publics et protégés d'une classe de base protégée deviennent des membres protégés de la classe dérivée. Nous retrouvons le même principe que pour une dérivation privée, la seule différence concerne les enfants éventuels de la classe dérivée, puisque dans ce cas là, les petits enfants peuvent atteindre des membres protégés.


Choix du chapitre Constructions

Pour une classe, à chaque fois que nous fabriquons un nouvel objet, le souci porte sur sa phase de création. En effet, il est toujours important que l'état de l'objet soit prédéterminé afin que la suite de ses comportements respecte au mieux les désirs du programmeur. Lorsque nous parlons de l'état de l'objet, il s'agit en fait de la valeur de chacun des attributs. C'est le (ou les) constructeur qui gère ce genre de problème au moment de la phase de création.

Vous pouvez bien entendu ne pas utiliser de constructeurs, c'est alors la performance en terme de rapidité qui est privilégiée. Dans ce cas précis, la valeur des attributs est alors totalement aléatoire ce qui correspond également à un état aléatoire de l'objet. Il est toutefois souvent préférable qu'un objet soit dans un état bien précis dès sa naissance.

Dans le mécanisme d'héritage, les enfants devront également s'occuper de cette phase de création. Ils héritent de leur parent, et récupèrent donc tout leur comportement, phase de création comprise.

L'enfant doit uniquement se préoccuper de ce qui fait sa spécificité par rapport au parent. Le parent intègre déjà toute la partie générale. Pour la phase de construction c'est la même chose, l'enfant doit gérer ses propres attributs alors que le parent s'est déjà occupé des siens. Les constructeurs du parent ont déjà été mis au point (la classe parente est normalement autonome et elle est capable de se créer toute seule).

Nous voyons là l'intérêt de l'héritage. Nous ne nous occupons que d'une petite partie à la fois. Par contre, si le parent possède plusieurs constructeurs, ce qui suppose qu'il possède plusieurs possibilités de création, il faudra que l'enfant en tienne compte ou du moins choisisse le constructeur qui convient le mieux à sa propre création.

Nous retrouvons là la même problématique que nous avons déjà rencontré lors de l'étude de la composition, c'est-à-dire, lorsqu'un objet est intégré dans une classe. Nous avons bien souligné que cet objet doit être construit pour pouvoir l'utiliser de façon correcte ultérieurement. Par chance, nous utilisons la même syntaxe, c'est-à-dire les listes d'initialisation.

La classe de base et la classe dérivée possèdent un constructeur par défaut 

La création d'un objet se déroule en quatre phases :

  1. Allocation mémoire nécessaire pour contenir l'ensemble des attributs que comporte l'objet. Puisqu'il s'agit d'un héritage, l'objet alloue l'espace mémoire pour ses propres attributs ainsi que l'espace mémoire nécessaire aux attributs délivrés par l'héritage. Sinon l'objet ne serait pas complet.
  2. Appel du constructeur de la classe dérivée. Ce constructeur est appelé mais pas encore exécuté. En effet, le constructeur de la classe dérivée ne s'occupe uniquement de ce qui fait la spécificité de la classe, c'est-à-dire, initialiser ses propres attributs. Avant d'effectuer cela, il faut être sûr que toute la structure générale soit elle-même bien initialisée. C'est la classe de base qui s'occupe justement de la structure de base et qui gère l'initialisation de ces propres attributs. Donc, avant l'exécution du constructeur de la classe dérivée, c'est le constructeur de la classe de base qui est appelé.
  3. Appel et exécution du constructeur de la classe de base. A moins que la classe de base soit elle-même une classe dérivée d'une autre classe de base, les instructions qui constituent le corps du constructeur sont exécutées. Au minimum, ces instructions consistent à donner une valeur correcte aux attributs afin que l'objet par la suite n'ait pas de comportement aléatoire. (Si cette classe de base est également une classe dérivée, le système appelle d'abord le constructeur de sa classe de base avant l'exécution du constructeur).
  4. Exécution du constructeur de la classe dérivée. Puisque la partie générale est bien initialisée, nous pouvons nous occuper de la partie spécifique à la classe dérivée. Les instructions du corps du constructeur sont donc exécutées. Il s'agit également d'initialiser les attributs relatifs à la classe dérivée.

La création d'un objet passe systématiquement par ces quatre phases. Ainsi, nous sommes sûr que l'objet est correctement initialisé. Chaque phase joue son rôle et s'occupe que d'une petite partie, ce qui rend la lecture plus facile. La maintenance s'en trouvera également d'autant plus simplifiée.

Lorsque vous ne disposez d'aucun constructeur, ces quatre phases sont quand même accomplies. Effectivement, les constructeurs par défaut existent. Par contre, ils ne disposent d'aucunes instructions par défaut. En fait, ils ne font rien. Du coup, la valeur de chacun des attributs est généralement aléatoire, sauf dans le cas d'un objet déclaré en tant que variable globale.

La classe de base dispose d'un constructeur par défaut et la classe dérivée d'un constructeur avec un paramètre

Par rapport au scénario précédent, rien ne change vraiment, les quatre phases sont appelées dans le même ordre.

La classe de base dispose d'un constructeur avec paramètres 

Lorsque nous disposons d'un constructeur par défaut, l'appel se fait implicitement, c'est-à-dire automatiquement. Lorsque nous avons un constructeur avec arguments, cette fois-ci, il est nécessaire de faire un appel explicite afin d'envoyer les bons arguments au constructeur pour que l'initialisation des attributs correspondent à l'objet désiré.

Du coup, pour la classe dérivée, il est nécessaire de disposer au moins d'un constructeur qui fasse un appel explicite au constructeur de la classe de base en propageant les bons arguments nécessaires aux attributs généraux relatifs à la classe de base. Nous avons déjà rencontré ce genre de situation. La solution consiste à utiliser une liste d'initialisation, dont la syntaxe d'ailleurs se rapproche de l'écriture de l'héritage, notamment avec l'utilisation de l'opérateur «  :  ».


Quelque soit les situations, nous disposons toujours des quatre mêmes phases pour la création de l'objet et toujours dans le même ordre.

  1. Allocation mémoire nécessaire pour contenir tous les attributs que comporte l'objet.
  2. Appel du constructeur de la classe dérivée. Ce constructeur est appelé mais pas encore exécuté. Appel du constructeur de la classe de base spécifié par la liste d'initialisation en récupérant les bons arguments pour les attributs de la classe de base.
  3. Appel et exécution du constructeur de la classe de base. A moins que la classe de base soit elle-même une classe dérivée d'une autre classe de base, les instructions qui constituent le corps du constructeur sont exécutées.
  4. Exécution du constructeur de la classe dérivée. Puisque la partie générale est bien initialisée, nous pouvons nous occuper de la partie spécifique à la classe dérivée. Les instructions du corps du constructeur sont donc exécutées.

Pour le concepteur de la classe dérivée, il n'est pas nécessaire de se pencher de nouveau sur les attributs de la classe de base, il devient un simple utilisateur. Il se préoccupe seulement des attributs de la classe dérivée. D'ailleurs, si vous remarquez bien, il est impossible d'atteindre directement les attributs de la classe de base puisqu'ils sont privés.

Choix du chapitre Compatibilité entre classe de base et classe dérivée

Nous pouvons toujours dire qu'un cercle est aussi une forme et qu'un élève est aussi une personne. La réciproque n'est pas vraie. Selon le besoin, nous savons établir des conversions implicites qui permettent de passer d'un type vers un autre. Dans le cadre de l'héritage, il sera possible de passer d'une classe dérivée vers une classe de base. Par contre l'inverse ne sera pas possible.

 

  1. Création de l'objet f1.
  2. Création de l'objet c1.
  3. Possibilité de déplacer le cercle c1 puisque la méthode a été héritée.
  4. Agrandissement du cercle c1 grâce à la méthode agrandir définie par la classe Cercle.
  5. Â droite et à gauche de l'opérateur d'affectation, les types sont différents. C'est toujours le type qui est à droite qui est transformé vers le type de gauche. Ici, le cercle c1 est aussi une forme, donc la conversion implicite est lancée. Cette démarche paraît normale puisqu'à l'issue de cette opération, les attributs de l'objet f1 sont parfaitement définis.
  6. Dans ce contexte, il est également possible de changer de position puisque, de toute façon, la méthode associée a été définie dans la classe Forme.
  7. Par contre, à partir d'une forme, il est impossible de demander un agrandissement puisque cette démarche n'existe que d'après la classe Cercle. Une erreur de compilation est alors déclenchée.
  8. Tentative de création d'un cercle à partir d'une forme. Nous obtenons également une erreur de compilation. Il s'agit également d'un changement de type. Si ce casting était toléré, cela voudrait dire que nous autoriserions d'avoir des attributs avec des valeurs aléatoires. Effectivement f1 ne dispose pas de rayon, ainsi l'attribut rayon de c2 se retrouverait sans aucune valeur bien précise. Cette démarche n'est pas tolérée par le compilateur et nous le comprenons. Par ailleurs, dans le chapitre précédent, nous avons découvert qu'au moment de la création d'un objet d'une classe dérivée, le constructeur de cette classe dérivée faisait appel à un constructeur de la classe de base. En aucun moment nous avons l'inverse, c'est-à-dire un constructeur de la classe de base qui fait appel à un constructeur de la classe dérivée. Cela n'aurait aucun sens.

Choix du chapitre Comportements par défaut et héritage

Constructeur de copie – écriture implicite :

Par défaut, le constructeur de copie propose une copie membre à membre. Les attributs de l'objet à créer sont initialisés par rapport aux attributs de l'objet (qui sert de copie) passé en argument. Le même comportement par défaut reste vrai pour un objet de la classe dérivée.

Un constructeur de classe de base est toujours invoqué avant l'exécution du constructeur de la classe dérivée. Le constructeur de copie est également un constructeur. Il ne fait donc pas exception à cette règle, ce qui est logique. Nous retrouvons donc les quatre mêmes phases pour la création d'un objet par un autre.

Lorsque nous écrivons les lignes de code ci-contre, avec la création d'un objet cercle par rapport à un autre objet cercle, le constructeur de copie par défaut de la classe Cercle est sollicité. Rien n'a été spécialement écrit explicitement dans aucune des deux classes, mais cela correspond à ce qui vous est montré ci-dessous.

Comme je viens de le rappeler, un constructeur de copie reste un constructeur. De ce fait, lorsque le constructeur de copie de la classe dérivée fait référence au constructeur de la classe de base, il passe par la liste d'initialisation.

Par ailleurs, quand le constructeur de copie de la classe dérivée est sollicité, il doit faire référence également au constructeur de copie de la classe de base. Effectivement, le constructeur de copie de la classe dérivée doit s'occuper d'exécuter la copie de chacun de ces membres, alors que la classe de base s'occupe de copier ses propres membres.

Dans ces conditions, on voit que le constructeur de copie de la classe de base Forme doit recevoir en argument, non pas l'objet cercle tout entier, mais seulement ce qui, dans cercle représente la Forme. C'est là qu'intervient la possibilité de conversion implicite d'une classe dérivée vers une classe de base comme nous l'avons découvert dans le chapitre précédent. Nous pouvons donc passé directement l'objet cercle au constructeur de copie de la classe de base Forme prévu dans la liste d'initialisation.

Constructeur de copie – écriture explicite :

Vous savez que le comportement par défaut n'est pas toujours souhaitable, notamment lorsque nous disposons de variables dynamiques au sein même de la classe. L'héritage n'exclut pas cette problématique, il faut alors gérer la situation. En fait trois cas peuvent se présenter :

  1. La classe de base possède au moins une variable dynamique, mais pas la classe dérivée  : Dans ce cas, il faut uniquement redéfinir le constructeur de copie de la classe de base. Lorsque nous tenterons de créer un objet de la classe dérivée par copie, l'appel du constructeur de copie de la classe de base se fera implicitement sans aucun problème particulier.
  2. La classe de base et la classe dérivée possèdent toutes les deux des variables dynamiques  : Dans ce cas, il faut redéfinir, bien entendu, les deux constructeurs de copie. Mais attention, lors de la définition du constructeur de copie de la classe dérivée, vous devez impérativement faire un appel explicite au constructeur de copie de la classe de base grâce à la liste d'initialisation. L'appel implicite au constructeur de copie de la classe de base ne marche pas dès que vous redéfinissez le constructeur de copie de la classe dérivée .
  3. La classe dérivée possède une variable dynamique, mais pas la classe de base  : Vous êtes donc obligé de redéfinir le constructeur de copie de la classe dérivée, ce qui implique que là aussi, vous devez faire un appel explicite au constructeur de copie par défaut à l'aide de la liste d'initialisation.

Pour illustrer ces propos, nous allons nous servir des classe Personne et Elève. Pour la classe Elève, nous allons gérer l'ensemble des notes au travers d'un tableau classique. Puisque le nombre de notes est variable, nous sommes obligé de prendre un pointeur. Du coup, nous avons effectivement une variable dynamique (si nous avions pris une classe vector pour gérer les notes, nous n'aurions pas ce genre de problème).

Cela peut paraître dommage de faire un appel explicite au constructeur de copie de la classe de base lorsque nous redéfinissons le constructeur de copie de la classe dérivée. Ce que nous pouvons dire, c'est que, d'une part, c'est très vite écrit, d'autre part, nous avons la possibilité de choisir un autre constructeur que le constructeur de copie. En effet, puisque nous passons par une liste d'initialisation, nous avons la liberté de décider du constructeur à prendre. Ainsi, par rapport à l'exemple précédent, nous aurions pu aussi écrire ce qui vous est présenté dans le codage ci-dessous.

Opérateur d'affectation :

Pour l'opérateur d'affectation, nous devons avoir le même type de raisonnement que pour le constructeur de copie.

Lorsque nous utilisons l'affectation par défaut, une copie membre à membre est réalisée. Puisque que la classe dérivée hérite de tous les attributs de la classe de base, lorsque nous effectuons une affectation entre deux objets de la classe dérivée, l'ensemble des attributs est bien copié d'un objet vers l'autre, avec d'abord, la copie des attributs relatifs à la classe de base.

Si nous avons à gérer des variables dynamiques au sein d'une classe ou des deux classes, nous devons procéder exactement de la même façon que pour le constructeur de copie. Toutefois, il existe une différence majeure lorsque vous devez redéfinir l'opérateur d'affectation de la classe dérivée. Un constructeur dispose d'une liste d'initialisation, mais pas l'opérateur d'affectation. Du coup, le mécanisme de transfert d'argument qui permettait l'appel vers la bonne méthode ne peut-être utilisé. Vous devez donc écrire explicitement, à l'intérieur de la méthode définissant l'opérateur d'affectation, tout ce qui concerne l'affectation de l'objet dérivée, y compris les attributs de la classe de base. Dans ce cas de figure, il est souhaitable que les attributs de la classe de base aient un statut protégé.

Destructeur :

Les différentes phases qui constituent la destruction de l'objet s'effectuent dans l'ordre inverse de la construction, c'est-à-dire :

  1. Appel du destructeur de la classe dérivée.
  2. Exécution du destructeur de la classe dérivée .
  3. Appel et exécution du destructeur de la classe de base.
  4. Libération de la mémoire utilisée par l'objet .

Ces quatre phases existent que le ou les destructeurs soient redéfinis ou pas. Les destructeurs sont à redéfinir dans le cas où les classes disposent de variables dynamiques. Dans ce cas là, les appels se feront implicitement sans soucis particuliers.

Choix du chapitre Redéfinition des méthodes

ATTENTION - Il ne faut pas mélanger la redéfinition et la surdéfinition.

  1. Une surdéfinition (ou surcharge) permet d'utiliser plusieurs méthodes qui portent le même nom au sein d'une même classe, avec une signature différente, pour que le système puisse s'y retrouver.
  2. Une redéfinition permet de fournir une nouvelle définition d'une méthode d'une classe ascendante et ainsi de substituer la description qui en été faite. Nous avons également le même nom que la méthode parente mais surtout avec une signature rigoureusement identique. La redéfinition constitue la base du polymorphisme.

Finalement, dans les exemples précédents, nous avons surtout utilisé la surdéfinition puisque nous avions plusieurs constructeurs pour une même classe. Dans ce chapitre, nous allons nous préoccuper de la redéfinition en sachant, par ailleurs, que la redéfinition et la surdéfinition peuvent coexister sans problème.

Reprenons l'exemple de la classe Elève issue d'une classe Personne. Nous pouvons avoir besoin, pour notre application, d'afficher la description, soit de la personne, soit de l'élève. Nous disposons donc d'une méthode affiche sur chacune de ces deux classes. Remarquez que nous avons choisi le même nom, il s'agit donc bien d'une redéfinition.

Il est effectivement nécessaire de redéfinir la méthode de la classe de base puisque cette dernière affiche uniquement l'identité de la personne, alors que la méthode affiche de la classe dérivée doit proposer en plus l'affichage de l'ensemble des notes.

Ceci dit, la méthode affiche de la classe Elève doit également proposer l'affichage de l'identité de l'élève. Dans la méthode affiche de la classe Elève, nous pouvons donc réécrire ce qui a déjà été écrit dans la méthode affiche de la classe Personne. Le problème, c'est qu'il faut savoir ce qui est déjà écrit, ce qui n'est pas toujours évident et surtout, c'est une perte de temps. Le mieux c'est de faire appel à la méthode affiche de la classe Personne. Par contre, il faut bien préciser qu'il s'agit effectivement de la méthode affiche de la classe Personne, sinon nous aurons un appel récursif. Rappelez-vous que lorsque nous devons spécifier une méthode par rapport à une classe, c'est l'opérateur de portée «  ::  » qui sert de qualificateur.

Même si c'est très rarement utilisé, vous pouvez faire un appel explicite à une méthode d'une classe ascendante.
.