Aller au contenu principal

Héritage

L'héritage est un concept fondamental de la programmation orientée objet (POO) qui permet de créer de nouvelles classes (classes enfants ou sous-classes) basées sur des classes existantes (classes parent ou superclasses). L'héritage favorise la réutilisation du code, car les classes enfants héritent des propriétés (attributs) et des comportements (méthodes) de leurs classes parent.

Avantages de l'héritage

L'héritage offre plusieurs avantages majeurs en programmation :

  1. Réutilisation du code : Évite la duplication en héritant des fonctionnalités existantes plutôt que de les réécrire
  2. Organisation hiérarchique : Crée une structure logique qui reflète les relations naturelles entre les concepts
  3. Facilité de maintenance : Les modifications apportées à la classe parent se propagent automatiquement aux classes enfants
  4. Extensibilité : Facilite l'ajout de nouvelles fonctionnalités sans modifier le code existant
  5. Polymorphisme : Permet d'écrire du code plus flexible et générique qui fonctionne avec différents types d'objets
  6. Cohérence : Garantit que les classes liées partagent une interface commune

Principes clés de l'héritage

  • Héritage des membres : Les classes enfants héritent de tous les membres (attributs et méthodes) de leurs classes parent, à l'exception des membres privés.
  • Accès aux membres :
    • Les membres publics (public) de la classe parent sont accessibles aux classes enfants et à l'extérieur de la hiérarchie d'héritage.
    • Les membres protégés (protected) sont accessibles à la classe en cours et aux classes enfants, mais pas aux autres classes.
    • Les membres privés (private) ne sont accessibles qu'à l'intérieur de la classe parent et ne sont pas hérités par les classes enfants.
  • Extension et spécialisation : Les classes enfants peuvent ajouter de nouveaux membres (attributs et méthodes) ou modifier le comportement des méthodes héritées (redéfinition ou override).
  • La classe Object : Toutes les classes Java héritent implicitement de la classe Object, qui fournit des méthodes de base comme toString(), equals(), et hashCode().

Syntaxe de l'héritage en Java

class Enfant extends Parent {
// ...
}

Redéfinition de méthodes (Override)

Attention à la terminologie :

  • Redéfinition (Override) : Fournir une nouvelle implémentation pour une méthode héritée avec la même signature
  • Surcharge (Overload) : Créer plusieurs méthodes avec le même nom mais des paramètres différents (vu avec les constructeurs)

La redéfinition de méthode permet à une classe enfant de fournir une implémentation spécifique pour une méthode héritée de sa classe parent. La méthode de la classe enfant doit avoir :

  • Le même nom
  • Les mêmes types de paramètres
  • Le même type de retour (ou un sous-type)

L'annotation @Override est fortement recommandée pour indiquer une redéfinition et aider le compilateur à détecter les erreurs.

Exemple de redéfinition

class Animal {
public void faireDuBruit() {
System.out.println("L'animal fait un bruit.");
}
}

class Chien extends Animal {
@Override
public void faireDuBruit() {
System.out.println("Le chien aboie : Wouf !");
}
}

class Chat extends Animal {
@Override
public void faireDuBruit() {
System.out.println("Le chat miaule : Miaou !");
}
}

public class Main {
public static void main(String[] args) {
Animal animal = new Animal();
Chien chien = new Chien();
Chat chat = new Chat();

animal.faireDuBruit(); // Output: L'animal fait un bruit.
chien.faireDuBruit(); // Output: Le chien aboie : Wouf !
chat.faireDuBruit(); // Output: Le chat miaule : Miaou !
}
}

Polymorphisme

Le polymorphisme est la capacité d'un objet à prendre plusieurs formes. Grâce à l'héritage et à la redéfinition, un objet de type classe parent peut en réalité référencer un objet de type classe enfant. Cela permet d'écrire du code plus générique et flexible.

Exemple de polymorphisme

public class Main {
public static void main(String[] args) {
Animal animal = new Animal();
Chien chien = new Chien();
Chat chat = new Chat();

animal.faireDuBruit(); // Output: L'animal fait un bruit.
chien.faireDuBruit(); // Output: Le chien aboie : Wouf !
chat.faireDuBruit(); // Output: Le chat miaule : Miaou !

// Polymorphisme : un objet de type Animal peut référencer un objet de type Chien ou Chat
Animal autreAnimal = chien;
autreAnimal.faireDuBruit(); // Output: Le chien aboie : Wouf !

autreAnimal = chat;
autreAnimal.faireDuBruit(); // Output: Le chat miaule : Miaou !
}
}

Référence polymorphe : La variable autreAnimal peut référencer un objet de type Chien ou Chat grâce au polymorphisme. Lorsqu'on appelle la méthode faireDuBruit() sur autreAnimal, c'est l'implémentation spécifique de la méthode (celle de Chien ou Chat) qui est exécutée, et non celle de la classe Animal. C'est le type réel de l'objet (à l'exécution) qui détermine quelle méthode est appelée, pas le type de la variable.

Mots-clés super et this

Le mot-clé super

  • super(...) : Appelle le constructeur de la classe parent
    • Doit être la première instruction du constructeur enfant
    • Si absent, Java appelle automatiquement super() (constructeur sans paramètres)
  • super.methode() : Appelle une méthode de la classe parent
  • super.attribut : Accède à un attribut de la classe parent (rarement utilisé)

Le mot-clé this

  • this : Référence l'objet courant (l'instance de la classe)
  • Utilisé pour distinguer les attributs de la classe des paramètres
  • this(...) : Appelle un autre constructeur de la même classe (vu au chapitre 3)

Exemple complet

class Animal {
String nom;

public Animal(String nom) {
this.nom = nom; // "this" pour référencer l'attribut de l'objet courant
}

public void manger() {
System.out.println(this.nom + " mange.");
}
}

class Chien extends Animal {
String race;

public Chien(String nom, String race) {
super(nom); // "super" pour appeler le constructeur de la classe parent
this.race = race; // "this" pour référencer l'attribut de l'objet courant
}

public void aboyer() {
System.out.println(this.nom + " aboie.");
}

@Override
public void manger() {
super.manger(); // "super" pour appeler la méthode de la classe parent
System.out.println(this.nom + " mange des croquettes.");
}
}

public class Main {
public static void main(String[] args) {
Chien monChien = new Chien("Rex", "Berger Allemand");

monChien.manger(); // Output: Rex mange.
// Rex mange des croquettes.
monChien.aboyer(); // Output: Rex aboie.
}
}

Ordre d'appel des constructeurs

Lorsqu'on crée un objet d'une classe enfant, les constructeurs sont appelés dans l'ordre suivant :

  1. Constructeur de la classe parent (appelé via super() explicite ou implicite)
  2. Initialisation des attributs de la classe enfant
  3. Corps du constructeur enfant

Exemple démonstratif

class Vehicule {
String type;

public Vehicule(String type) {
this.type = type;
System.out.println("1. Constructeur Vehicule appelé : " + type);
}
}

class Voiture extends Vehicule {
String marque;

public Voiture(String marque) {
super("Voiture"); // Doit être la PREMIÈRE instruction
System.out.println("2. Constructeur Voiture appelé : " + marque);
this.marque = marque;
}
}

public class Main {
public static void main(String[] args) {
Voiture v = new Voiture("Tesla");
// Output:
// 1. Constructeur Vehicule appelé : Voiture
// 2. Constructeur Voiture appelé : Tesla
}
}

Important : Si vous ne mettez pas super(...) explicitement, Java ajoute automatiquement super() comme première instruction. Si la classe parent n'a pas de constructeur sans paramètres, cela causera une erreur de compilation.

Modificateur d'accès protected

Le modificateur protected est particulièrement utile avec l'héritage : il rend un membre accessible aux classes enfants tout en le gardant caché des autres classes.

Exemple avec protected

class CompteBancaire {
private String numeroCompte; // Privé : inaccessible aux enfants
protected double solde; // Protégé : accessible aux enfants

public CompteBancaire(String numeroCompte, double soldeInitial) {
this.numeroCompte = numeroCompte;
this.solde = soldeInitial;
}

public double getSolde() {
return solde;
}
}

class CompteEpargne extends CompteBancaire {
private double tauxInteret;

public CompteEpargne(String numeroCompte, double soldeInitial, double tauxInteret) {
super(numeroCompte, soldeInitial);
this.tauxInteret = tauxInteret;
}

public void ajouterInterets() {
// Peut accéder directement à 'solde' car il est protected
solde += solde * tauxInteret;
System.out.println("Intérêts ajoutés. Nouveau solde : " + solde);
}
}

Quand utiliser protected ?

  • Lorsque vous voulez qu'une classe enfant accède directement à un attribut
  • Pour des méthodes "utilitaires" destinées aux sous-classes
  • En général, préférez quand même private + getters/setters pour plus de contrôle

Classes abstraites

Une classe abstraite est une classe qui ne peut pas être instanciée directement. Elle sert de modèle pour les classes concrètes (non abstraites) qui héritent d'elle.

Caractéristiques :

  • Déclarée avec le mot-clé abstract
  • Ne peut pas être instanciée (pas de new ClasseAbstraite())
  • Peut contenir des méthodes abstraites (sans corps) ET des méthodes concrètes (avec corps)
  • Peut avoir des attributs, des constructeurs (appelés par les classes enfants)

Méthodes abstraites

Une méthode abstraite est une méthode déclarée sans implémentation (sans corps) :

  • Déclarée avec le mot-clé abstract
  • Pas d'accolades {}, seulement un point-virgule ;
  • Doit être implémentée par toutes les classes concrètes qui héritent de la classe abstraite
  • Force les classes enfants à fournir une implémentation

Exemple complet de classe abstraite

abstract class Forme {
protected String couleur;

// Constructeur d'une classe abstraite
public Forme(String couleur) {
this.couleur = couleur;
}

// Méthode abstraite : pas d'implémentation
public abstract double calculerAire();
public abstract double calculerPerimetre();

// Méthode concrète : avec implémentation
public void afficherInfo() {
System.out.println("Forme de couleur " + couleur);
System.out.println("Aire : " + calculerAire());
System.out.println("Périmètre : " + calculerPerimetre());
}
}

class Cercle extends Forme {
private double rayon;

public Cercle(double rayon, String couleur) {
super(couleur); // Appel du constructeur de la classe abstraite
this.rayon = rayon;
}

@Override
public double calculerAire() {
return Math.PI * rayon * rayon;
}

@Override
public double calculerPerimetre() {
return 2 * Math.PI * rayon;
}
}

class Rectangle extends Forme {
private double largeur;
private double hauteur;

public Rectangle(double largeur, double hauteur, String couleur) {
super(couleur);
this.largeur = largeur;
this.hauteur = hauteur;
}

@Override
public double calculerAire() {
return largeur * hauteur;
}

@Override
public double calculerPerimetre() {
return 2 * (largeur + hauteur);
}
}

class Carre extends Rectangle {
public Carre(double cote, String couleur) {
super(cote, cote, couleur); // Un carré est un rectangle spécial
}
}

public class Main {
public static void main(String[] args) {
// Forme f = new Forme("Rouge"); // ERREUR : ne peut pas instancier une classe abstraite

Forme cercle = new Cercle(5, "Rouge");
Forme rectangle = new Rectangle(4, 6, "Bleu");
Forme carre = new Carre(5, "Vert");

cercle.afficherInfo();
System.out.println();
rectangle.afficherInfo();
System.out.println();
carre.afficherInfo();

// Polymorphisme avec un tableau
Forme[] formes = {cercle, rectangle, carre};
double aireTotal = 0;
for (Forme f : formes) {
aireTotal += f.calculerAire();
}
System.out.println("\nAire totale : " + aireTotal);
}
}

Modificateur final

Le mot-clé final peut être utilisé avec les classes et les méthodes pour empêcher l'héritage ou la redéfinition.

Classe finale

Une classe déclarée final ne peut pas être héritée :

final class MaClasseFinale {
// Cette classe ne peut pas avoir de sous-classes
}

// class Enfant extends MaClasseFinale { // ERREUR de compilation
// ...
// }

Exemples en Java : String, Integer, Math sont des classes finales.

Quand utiliser final pour une classe ?

  • Pour des raisons de sécurité : empêcher la modification du comportement
  • Pour des raisons de performance : le compilateur peut optimiser
  • Pour garantir l'immuabilité (comme String)

Méthode finale

Une méthode déclarée final ne peut pas être redéfinie par les sous-classes :

class Parent {
public final void methodeImportante() {
System.out.println("Cette méthode ne peut pas être modifiée");
}

public void methodeNormale() {
System.out.println("Cette méthode peut être redéfinie");
}
}

class Enfant extends Parent {
// @Override
// public void methodeImportante() { // ERREUR de compilation
// ...
// }

@Override
public void methodeNormale() {
System.out.println("Méthode redéfinie");
}
}

Hiérarchie d'héritage à plusieurs niveaux

Java permet de créer des chaînes d'héritage sur plusieurs niveaux. Voici un exemple complet :

// Niveau 1 : Classe de base
class EtreVivant {
protected String nom;

public EtreVivant(String nom) {
this.nom = nom;
}

public void respirer() {
System.out.println(nom + " respire");
}
}

// Niveau 2 : Spécialisation
class Animal extends EtreVivant {
protected int nombrePattes;

public Animal(String nom, int nombrePattes) {
super(nom);
this.nombrePattes = nombrePattes;
}

public void seDeplacer() {
System.out.println(nom + " se déplace avec " + nombrePattes + " pattes");
}

public void faireDuBruit() {
System.out.println(nom + " fait du bruit");
}
}

// Niveau 3 : Spécialisation encore plus précise
class Mammifere extends Animal {
protected boolean aPoils;

public Mammifere(String nom, int nombrePattes, boolean aPoils) {
super(nom, nombrePattes);
this.aPoils = aPoils;
}

public void allaiter() {
System.out.println(nom + " allaite ses petits");
}
}

// Niveau 4 : Classe finale concrète
class Chien extends Mammifere {
private String race;

public Chien(String nom, String race) {
super(nom, 4, true); // Un chien a 4 pattes et des poils
this.race = race;
}

@Override
public void faireDuBruit() {
System.out.println(nom + " aboie : Wouf wouf!");
}

public void rapporterBalle() {
System.out.println(nom + " rapporte la balle");
}
}

class Chat extends Mammifere {
private boolean estDomestique;

public Chat(String nom, boolean estDomestique) {
super(nom, 4, true);
this.estDomestique = estDomestique;
}

@Override
public void faireDuBruit() {
System.out.println(nom + " miaule : Miaou!");
}

public void grimper() {
System.out.println(nom + " grimpe aux arbres");
}
}

public class Main {
public static void main(String[] args) {
Chien rex = new Chien("Rex", "Berger Allemand");
Chat felix = new Chat("Félix", true);

// Rex hérite de toute la hiérarchie
rex.respirer(); // de EtreVivant
rex.seDeplacer(); // de Animal
rex.allaiter(); // de Mammifere
rex.faireDuBruit(); // redéfini dans Chien
rex.rapporterBalle(); // spécifique à Chien

System.out.println();

// Polymorphisme à différents niveaux
EtreVivant ev = rex;
Animal animal = rex;
Mammifere mammifere = rex;

ev.respirer();
animal.faireDuBruit(); // Appelle la version de Chien grâce au polymorphisme
mammifere.allaiter();
}
}

Quand utiliser l'héritage et les classes abstraites ?

Utiliser l'héritage quand :

  • Il existe une relation "est un" entre deux classes (un Chien est un Animal)
  • Vous voulez réutiliser du code d'une classe existante
  • Les classes enfants sont des spécialisations de la classe parent
  • Vous voulez bénéficier du polymorphisme

Utiliser une classe abstraite quand :

  • Vous avez un concept général qui ne doit pas être instancié directement (une Forme générique n'a pas de sens)
  • Vous voulez imposer un contrat : les classes enfants doivent implémenter certaines méthodes
  • Vous voulez partager du code commun tout en forçant certaines implémentations spécifiques
  • Plusieurs classes partagent des comportements mais ont des implémentations différentes pour certaines méthodes

Ne PAS utiliser l'héritage quand :

  • La relation est "a un" plutôt que "est un" (utilisez la composition à la place)
  • Exemple : Une Voiture a un Moteur → class Voiture { private Moteur moteur; }
  • Vous héritez juste pour réutiliser quelques méthodes (préférez la composition)
  • La hiérarchie devient trop profonde (plus de 3-4 niveaux devient difficile à maintenir)