Comment fonctionnent les classes abstraites en Java ?

Lors de la conception d’un modèle, nous voulons parfois créer une entité qui ne peut pas être instanciée directement. Par exemple, imaginons que nous avons une classe Canin avec les sous-classes Loup, Renard, et Coyote. Nous voulons que d’autres développeurs puissent créer des instances des sous-classes, mais peut-être que nous ne voulons pas qu’ils puissent créer une instance de Canin. En d’autres termes, nous voulons forcer tous les objets de Canin à avoir un type particulier à l’exécution.

Introduction aux Classes Abstraites

Voici les classes abstraites. Une classe abstraite est une classe déclarée avec le modificateur abstract qui ne peut pas être instanciée directement et peut contenir des méthodes abstraites. Voyons un exemple basé sur le modèle de données Canin :

public abstract class Canin {}
public class Loup extends Canin {}
public class Renard extends Canin {}
public class Coyote extends Canin {}

Dans cet exemple, d’autres développeurs peuvent créer des instances de Loup, Renard, ou Coyote, mais pas de Canin. Bien sûr, ils peuvent passer une référence de variable comme un Canin, mais l’objet sous-jacent doit être une sous-classe de Canin à l’exécution.

Mais attendez, il y a plus ! Une classe abstraite peut contenir des méthodes abstraites. Une méthode abstraite est une méthode déclarée avec le modificateur abstract qui ne définit pas de corps. Autrement dit, une méthode abstraite force les sous-classes à surcharger la méthode.

Pourquoi voudrions-nous cela ? Le polymorphisme, bien sûr ! En déclarant une méthode abstraite, nous pouvons garantir qu’une certaine version sera disponible sur une instance sans avoir à spécifier quelle est cette version dans la classe parente abstraite.

public abstract class Canin {
    public abstract String getSon();
    public void aboyer() { System.out.println(getSon()); }
}

public class Loup extends Canin {
    public String getSon() {
        return "Wooooooof!";
    }
}

public class Renard extends Canin {
    public String getSon() {
        return "Glapissement!";
    }
}

public class Coyote extends Canin {
    public String getSon() {
        return "Rugissement!";
    }
}

Nous pouvons ensuite créer une instance de Renard et l’assigner au type parent Canin. La méthode surchargée sera utilisée à l’exécution.

public static void main(String[] p) {
    Canin r = new Renard();
    r.aboyer(); // Glapissement!
}

Facile jusqu’ici. Mais il y a certaines règles dont vous devez être conscient :

  • Seules les méthodes d’instance peuvent être marquées abstract dans une classe, pas les variables, constructeurs, ou méthodes static.
  • Une méthode abstraite ne peut être déclarée que dans une classe abstraite.
  • Une classe non abstraite qui étend une classe abstraite doit implémenter toutes les méthodes abstraites héritées.
  • La surcharge d’une méthode abstraite suit les règles existantes pour la surcharge des méthodes.

Voyons si vous pouvez repérer pourquoi chacune de ces déclarations de classe ne compile pas :

public class RenardFennec extends Canin {
    public int getSon() {
        return 10;
    }
}

public class RenardArctique extends Canin {}

public class LoupGeant extends Canin {
    public abstract repos();
    public String getSon() {
        return "Ouaf!";
    }
}

public class Chacal extends Canin {
    public abstract String nom;
    public String getSon() {
        return "Rire";
    }
}

Tout d’abord, la classe RenardFennec ne compile pas car c’est une surcharge de méthode invalide. En particulier, les types de retour ne sont pas covariants. La classe RenardArctique ne compile pas car elle ne surcharge pas la méthode abstraite getSon(). La classe LoupGeant ne compile pas car elle n’est pas abstraite mais déclare une méthode abstraite repos(). Enfin, la classe Chacal ne compile pas car les variables ne peuvent pas être marquées abstraites.

Une classe abstraite est le plus souvent utilisée lorsque vous voulez qu’une autre classe hérite des propriétés d’une classe particulière, mais que vous voulez que la sous-classe remplisse certains détails d’implémentation.

Plus tôt, nous avons dit qu’une classe abstraite est une classe qui ne peut pas être instanciée. Cela signifie que si vous essayez de l’instancier, le compilateur signalera une exception, comme dans cet exemple :

abstract class Alligator {
    public static void main(String... nourriture) {
        var a = new Alligator(); // NE COMPILE PAS
    }
}

Une classe abstraite peut être initialisée, mais seulement dans le cadre de l’instanciation d’une sous-classe non abstraite.

Déclaration de Méthodes Abstraites

Une méthode abstraite est toujours déclarée sans corps. Elle inclut également un point-virgule (;) après la déclaration de la méthode. Comme vous l’avez vu dans l’exemple précédent, une classe abstraite peut inclure des méthodes non abstraites, dans ce cas avec la méthode aboyer(). En fait, une classe abstraite peut inclure tous les mêmes membres qu’une classe non abstraite, y compris des variables, des méthodes static et d’instance, des constructeurs, etc.

Cela pourrait vous surprendre de savoir qu’une classe abstraite n’est pas obligée d’inclure des méthodes abstraites. Par exemple, le code suivant compile même s’il ne définit aucune méthode abstraite :

public abstract class Lama {
    public void macher() {}
}

Même sans méthodes abstraites, la classe ne peut pas être directement instanciée. Gardez un œil sur les méthodes abstraites déclarées en dehors des classes abstraites, comme celle-ci :

public class Aigrette { // NE COMPILE PAS
    public abstract void picorer();
}

Comme le modificateur final, le modificateur abstract peut être placé avant ou après le modificateur d’accès dans les déclarations de classe et de méthode, comme le montre cette classe Tigre :

abstract public class Tigre {
    abstract public int griffe();
}

Le modificateur abstract ne peut pas être placé après le mot-clé class dans une déclaration de classe, ni après le type de retour dans une déclaration de méthode. Les déclarations suivantes de Ours et hurler() ne compilent pas pour ces raisons :

public class abstract Ours { // NE COMPILE PAS
    public int abstract hurler(); // NE COMPILE PAS
}

Il n’est pas possible de définir une méthode abstraite qui a un corps ou une implémentation par défaut. Vous pouvez toujours définir une méthode par défaut avec un corps—vous ne pouvez simplement pas la marquer comme abstract. Tant que vous ne marquez pas la méthode comme final, la sous-classe a la possibilité de surcharger la méthode héritée.

Création d’une Classe Concrète

Une classe abstraite devient utilisable lorsqu’elle est étendue par une sous-classe concrète. Une classe concrète est une classe non abstraite. La première sous-classe concrète qui étend une classe abstraite est requise pour implémenter toutes les méthodes abstraites héritées. Cela inclut l’implémentation de toutes les méthodes abstraites héritées des interfaces héritées.

Lorsque vous voyez une classe concrète étendant une classe abstraite, vérifiez qu’elle implémente bien toutes les méthodes abstraites requises. Pouvez-vous voir pourquoi la classe Morse suivante ne compile pas ?

public abstract class Animal {
    public abstract String getNom();
}

public class Morse extends Animal {} // NE COMPILE PAS

Dans cet exemple, nous voyons que Animal est marquée comme abstract et Morse ne l’est pas, faisant de Morse une sous-classe concrète d’Animal. Puisque Morse est la première sous-classe concrète, elle doit implémenter toutes les méthodes abstraites héritées—getNom() dans cet exemple. Comme elle ne le fait pas, le compilateur signale une erreur avec la déclaration de Morse.

Nous soulignons la première sous-classe concrète pour une raison. Une classe abstraite peut étendre une classe non abstraite et vice versa. Chaque fois qu’une classe concrète étend une classe abstraite, elle doit implémenter toutes les méthodes qui sont héritées comme abstraites. Illustrons cela avec un ensemble de classes héritées :

public abstract class Mammifere {
    abstract void montrerCorne();
    abstract void mangerFeuille();
}

public abstract class Rhinoceros extends Mammifere {
    void montrerCorne() {} // Hérité de Mammifere
}

public class RhinocerosNoir extends Rhinoceros {
    void mangerFeuille() {} // Hérité de Mammifere
}

Dans cet exemple, la classe RhinocerosNoir est la première sous-classe concrète, tandis que les classes Mammifere et Rhinoceros sont abstraites. La classe RhinocerosNoir hérite la méthode mangerFeuille() comme abstraite et est donc requise pour fournir une implémentation, ce qu’elle fait. Qu’en est-il de la méthode montrerCorne() ? Puisque la classe parente, Rhinoceros, fournit une implémentation de montrerCorne(), la méthode est héritée dans la classe RhinocerosNoir comme une méthode non abstraite. Pour cette raison, la classe RhinocerosNoir est autorisée mais pas obligée de surcharger la méthode montrerCorne(). Les trois classes dans cet exemple sont correctement définies et compilent.

Et si nous changions la déclaration de Rhinoceros pour supprimer le modificateur abstract ?

public class Rhinoceros extends Mammifere { // NE COMPILE PAS
    void montrerCorne() {}
}

En changeant Rhinoceros en une classe concrète, elle devient la première classe non abstraite à étendre la classe abstraite Mammifere. Par conséquent, elle doit fournir une implémentation des deux méthodes montrerCorne() et mangerFeuille(). Puisqu’elle ne fournit qu’une de ces méthodes, la déclaration modifiée de Rhinoceros ne compile pas.

Essayons un exemple de plus. La classe concrète suivante Lion hérite de deux méthodes abstraites, getNom() et rugir() :

public abstract class Animal {
    abstract String getNom();
}

public abstract class GrosChat extends Animal {
    protected abstract void rugir();
}

public class Lion extends GrosChat {
    public String getNom() {
        return "Lion";
    }
    public void rugir() {
        System.out.println("Le Lion pousse un fort RUGISSEMENT!");
    }
}

Dans cet exemple de code, GrosChat étend Animal mais est marqué comme abstract ; par conséquent, il n’est pas requis de fournir une implémentation pour la méthode getNom(). La classe Lion n’est pas marquée comme abstract, et en tant que première sous-classe concrète, elle doit implémenter toutes les méthodes abstraites héritées non définies dans une classe parente. Les trois classes compilent avec succès.

Création de Constructeurs dans les Classes Abstraites

Même si les classes abstraites ne peuvent pas être instanciées, elles sont toujours initialisées à travers des constructeurs par leurs sous-classes. Par exemple, considérez le programme suivant :

abstract class Mammifere {
    abstract CharSequence macher();
    public Mammifere() {
        System.out.println(macher()); // Cette ligne compile-t-elle ?
    }
}

public class Ornithorynque extends Mammifere {
    String macher() { return "délicieux!"; }
    public static void main(String[] args) {
        new Ornithorynque();
    }
}

En utilisant les règles de constructeur, le compilateur insère un constructeur par défaut sans argument dans la classe Ornithorynque, qui appelle d’abord super() dans la classe Mammifere. Le constructeur Mammifere n’est appelé que lorsque la classe abstraite est initialisée à travers une sous-classe ; par conséquent, il y a une implémentation de macher() au moment où le constructeur est appelé. Ce code compile et imprime délicieux! à l’exécution.

Rappelez-vous que les classes abstraites sont initialisées avec des constructeurs de la même manière que les classes non abstraites. Par exemple, si une classe abstraite ne fournit pas de constructeur, le compilateur insérera automatiquement un constructeur par défaut sans argument.

La principale différence entre un constructeur dans une classe abstraite et une classe non abstraite est qu’un constructeur dans une classe abstraite ne peut être appelé que lorsqu’il est initialisé par une sous-classe non abstraite. Cela a du sens, car les classes abstraites ne peuvent pas être instanciées.

Repérer les Déclarations Invalides

Nous concluons notre discussion sur les classes abstraites avec une revue des problèmes potentiels que vous êtes plus susceptibles de rencontrer.

Pouvez-vous voir pourquoi chacune des méthodes suivantes ne compile pas ?

public abstract class Tortue {
    public abstract long manger() // NE COMPILE PAS
    public abstract void nager() {}; // NE COMPILE PAS
    public abstract int getAge() { // NE COMPILE PAS
        return 10;
    }
    public abstract void dormir; // NE COMPILE PAS
    public void allerDansCoquille(); // NE COMPILE PAS
}

La première méthode, manger(), ne compile pas car elle est marquée abstract mais ne se termine pas par un point-virgule (;). Les deux méthodes suivantes, nager() et getAge(), ne compilent pas car elles sont marquées abstract, mais elles fournissent un bloc d’implémentation entre accolades ({}). Une déclaration de méthode abstraite doit se terminer par un point-virgule sans aucune accolade. La méthode suivante, dormir, ne compile pas car il manque les parenthèses, (), pour les arguments de méthode. La dernière méthode, allerDansCoquille(), ne compile pas car elle n’est pas marquée abstract et doit donc fournir un corps entre accolades.

abstract et final Modificateurs

Que se passerait-il si vous marquiez une classe ou une méthode à la fois abstract et final ? Si vous marquez quelque chose abstract, vous avez l’intention que quelqu’un d’autre l’étende ou l’implémente. Mais si vous marquez quelque chose final, vous empêchez quiconque de l’étendre ou de l’implémenter. Ces concepts sont en conflit direct l’un avec l’autre.

En raison de cette incompatibilité, Java ne permet pas à une classe ou une méthode d’être marquée à la fois abstract et final. Par exemple, le fragment de code suivant ne compilera pas :

public abstract final class Tortue { // NE COMPILE PAS
    public abstract final void marcher(); // NE COMPILE PAS
}

Dans cet exemple, ni la déclaration de classe ni la déclaration de méthode ne compileront car elles sont marquées à la fois abstract et final.

abstract et private Modificateurs

Une méthode ne peut pas être marquée à la fois abstract et private. Cette règle a du sens si vous y réfléchissez. Comment définiriez-vous une sous-classe qui implémente une méthode requise si la méthode n’est pas héritée par la sous-classe ? La réponse est que vous ne pouvez pas, ce qui est pourquoi le compilateur se plaindra si vous essayez de faire ce qui suit :

public abstract class Baleine {
    private abstract void chanter(); // NE COMPILE PAS
}

public class BaleineBosse extends Baleine {
    private void chanter() {
        System.out.println("La baleine à bosse chante");
    }
}

Dans cet exemple, la méthode abstraite chanter() définie dans la classe parente Baleine n’est pas visible pour la sous-classe BaleineBosse. Même si BaleineBosse fournit une implémentation, elle n’est pas considérée comme une surcharge de la méthode abstraite puisque la méthode abstraite n’est pas héritée. Le compilateur reconnaît cela dans la classe parente et signale une erreur dès que private et abstract sont appliqués à la même méthode.

Bien qu’il ne soit pas possible de déclarer une méthode abstract et private, il est possible (bien que redondant) de déclarer une méthode final et private.

Si nous changions le modificateur d’accès de private à protected dans la classe parente Baleine, le code compilerait-il ?

public abstract class Baleine {
    protected abstract void chanter();
}

public class BaleineBosse extends Baleine {
    private void chanter() { // NE COMPILE PAS
        System.out.println("La baleine à bosse chante");
    }
}

Dans cet exemple modifié, le code ne compilera toujours pas, mais pour une raison complètement différente. Si vous vous souvenez des règles pour surcharger une méthode, la sous-classe ne peut pas réduire la visibilité de la méthode parente, chanter(). Parce que la méthode est déclarée protected dans la classe parente, elle doit être marquée comme protected ou public dans la classe enfant. Même avec les méthodes abstraites, les règles pour surcharger les méthodes doivent être suivies.

abstract et static Modificateurs

Comme nous l’avons discuté plus tôt, une méthode static ne peut qu’être masquée, pas surchargée. Elle est définie comme appartenant à la classe, pas à une instance de la classe. Si une méthode static ne peut pas être surchargée, alors il s’ensuit qu’elle ne peut pas non plus être marquée abstract puisqu’elle ne peut jamais être implémentée. Par exemple, la classe suivante ne compile pas :

abstract class Hippopotame {
    abstract static void nager(); // NE COMPILE PAS
}

Assurez-vous de connaître quels modificateurs peuvent et ne peuvent pas être utilisés ensemble, en particulier pour les classes abstraites et les interfaces.