La différence entre la surcharge et la redéfinition de méthode

Une des questions qui m’est le plus souvent posée en formation lorsque j’aborde des notions orientées objet : quelle différence entre une surcharge de méthode et une redéfinition de méthode ? Deux notions qui se ressemblent, mais qui sont pourtant totalement différentes.

De quoi parle-t-on ?

Avant d’expliquer en détail les différences entre ces deux fonctionnalités, revenons très rapidement sur certaines bases. À commencer par définir ce qu’est une méthode : il s’agit d’une notion purement objet, et correspond à une fonction définie dans une classe, qui associe un comportement aux instances de celle-ci.

Certains langages de programmation, de par leur nature, leur syntaxe, leur concepts, intègrent l’une ou l’autre de ces fonctionnalités (ou les deux).

On retrouve la notion de surcharge (overloading en anglais) aussi bien dans les langages dits impératifs (elle est alors appelée surcharge de fonction), que dans les langages orientés objets.

La notion de redéfinition (overriding en anglais), quant à elle, est inhérente à la programmation orientée objet (plus spécifiquement à l’héritage), et est souvent associée à un principe nommé polymorphisme.

Pour illustrer mes exemples, j’ai choisi le langage Java, où l’on retrouve ces deux principes. L’explication sera la même peu importe le langage utilisé, seule la syntaxe changera.

La surcharge de méthode

Dans les langages le permettant, le fait de surcharger une méthode consiste à déclarer une méthode portant le même nom qu’une autre méthode précédemment déclarée dans la même classe.

La méthode doit cependant être différente de celle qu’elle surcharge, et plus particulièrement au niveau de ses paramètres attendus. Ceux-ci doivent différer, selon au moins un de ces critères :

  • en type ;
  • en nombre ;
  • en ordre.

Le nom des paramètres n’entre évidemment nullement en jeu.

Bien entendu, les types de retour des différentes surcharges d’une même méthode peuvent différer.

La surcharge permet d’exécuter des comportements différents selon les informations transmises en entrée de la méthode, et évite ainsi, d’une part, de vérifier systématiquement la validité des données transmises, et d’autre part d’éviter d’encombrer le code des méthodes avec des successions de conditions. En contrepartie, le code est souvent plus verbeux et on y retrouve souvent des redondances.

À l’invocation, on n’indique pas quelle surcharge de la méthode on souhaite appeler : cela sera déterminé dynamiquement par le compilateur / interpréteur (selon le langage) en fonction des arguments transmis.

Commençons par l’exemple le plus basique qui soit : une méthode permettant de faire une division. En Java, le résultat de la division entre deux entiers (int) est toujours un entier, tandis que la division entre décimaux (double) donne toujours un décimal.

Dans une classe Calculatrice, nous avons la possibilité de créer deux méthodes nommées diviser() : l’une prendra deux int en paramètre, l’autre prendra deux double :

public class Calculatrice {

    /*
     * Division entre entiers
     */
    public int diviser(int num, int denom) {
        return num / denom;
    }

    /*
     * Division entre décimaux
     */
    public double diviser(double num, double denom) {
        return num / denom;
    }

}

À la compilation, le programme déterminera quelle méthode appeler, en fonction du type / nombre / ordre des arguments transmis lors de l’appel.

public class App {

    public static void main(String[] args) {

        Calculatrice c = new Calculatrice();
        
        System.out.println(c.diviser(2, 3));
        // Affiche 0

        System.out.println(c.diviser(2.0, 3.0));
        // Affiche 0.6666666666666666

        System.out.println(c.diviser(2, 3.0));
        // Affiche 0.6666666666666666 : cast implicite de int vers double
    
    }
}

Dans l’exemple précédent, on aurait très bien pu déclarer les méthodes diviser() en tant que méthodes statiques : le principe reste inchangé.

Bon, ici, la surcharge n’est peut-être pas totalement évidente, car la méthode prenant deux double en paramètre aurait très bien pu être invoquée avec deux int en argument dans le cas où l’on avait pas de surcharge (principe du cast implicite).

Un exemple un peu plus parlant, une méthode surchargée plusieurs fois :

public void maMethodeGeniale() {
    // Méthode ne prenant aucun paramètre et ne retournant rien 
}

public int maMethodeGeniale(int a, int b) {
    // Méthode prenant deux int en paramètre et retournant un int
    return 0;
}

public String maMethodeGeniale(String a, int b) {
    // Méthode prenant un String et un int en paramètre 
    // et retournant un String
    return "";
}

public int maMethodeGeniale(int a, String b) {
    // Méthode prenant un int et un String en paramètre (dans cet ordre) 
    // et retournant un int
    return 0;
}

De la même manière que l’on peut surcharger une méthode, il est également possible de surcharger un constructeur :

public class Personne {

    private String nom;
    private String prenom;
    private Adresse adresse;

    /*
     * Premier constructeur
     */
    public Personne(String nom, String prenom) {
        this.nom = nom;
        this.prenom = prenom;
        this.adresse = null;
    }

    /*
     * Second constructeur
     */
    public Personne(String nom, String prenom, Adresse adresse) {
        this.nom = nom;
        this.prenom = prenom;
        this.adresse = adresse;
    }

}

Il existe un cas particulier : celui des varargs, introduits avec Java 5. Il s’agit des paramètres pouvant prendre un nombre indéterminé d’arguments. Si une méthode possédant des varargs est surchargée, elle sera appelée dans tous les cas, sauf dans le cas où l’appel de la méthode correspond exactement au nombre de paramètres de la surcharge.

Bon, un exemple pour mieux comprendre :

public static int addition(int a, int b) {
    // Appelée dans le cas où deux arguments sont passés
    return a + b;
}

public static int addition(int ...args) {
    // Appelée dans tous les autres cas
    int resultat = 0;
    for (int a : args) {
        resultat += a;
    }
    return resultat;
}

En résumé …

La surcharge de méthode désigne le fait d’avoir deux méthodes (ou plus) déclarées dans une même classe, portant le même nom mais possédant des paramètres d’appel différents. Les paramètres doivent varier en type, en nombre, ou en ordre.

C’est notamment la raison pour laquelle la surcharge de fonction / méthode n’existe pas en Python ou JavaScript notamment, qui sont des langages où les paramètres ne sont pas typés, et qui intègrent pour la plupart la possibilité d’attribuer des valeurs par défaut à certains paramètres : l’intérêt d’implémenter la surcharge de fonction / méthode dans ces langages devient alors moindre.

La redéfinition de méthode

La redéfinition de méthode est indissociable de la programmation orientée objet, et plus spécifiquement d’un aspect de celle-ci : l’héritage (inheritance en anglais).

Lorsqu’une classe hérite d’une autre classe, elle bénéficie automatiquement de tout ou une partie des comportements de la classe de base (appelée également superclasse ou classe mère). Dans le cas de Java, il s’agit des attributs et méthodes déclarés public ou protected.

Exemple avec une classe de base Vehicule :

public class Vehicule {

    protected String marque;
    protected String modele;

    public Vehicule(String marque, String modele) {
        this.marque = marque;
        this.modele = modele;
    }

    public void seDeplacer() {
        System.out.println("Le véhicule " + this.marque + 
            " " + this.modele + " se déplace");
    }
}

Et une classe qui en hérite :

public class Voiture extends Vehicule {

    public Voiture(String marque, String modele) {
        super(marque, modele);
    }

}

L’appel dans un main :

public class App {

    public static void main(String[] args) {

        Voiture v = new Voiture("Renault", "Clio");
        v.seDeplacer();
    
    }
}

Ce qui affiche dans la console :

Le fait d’hériter d’une méthode peut être intéressant, cela permet de factoriser du code redondant : c’est d’ailleurs l’un des objectifs principaux de la relation d’héritage.

Cependant, il est fréquent que le comportement de la méthode d’une classe de base que récupère une classe dérivée (ou classe fille) ne corresponde pas exactement à ce que l’on souhaite faire dans celle-ci.

C’est ici que rentre en jeu la redéfinition de méthode : il s’agit de la possibilité de réécrire la méthode définie au préalable dans la classe de base. Tout simplement. La méthode redéfinie va alors écraser la méthode de base lorsqu’on l’invoquera sur une instance de la classe dérivée.

public class Voiture extends Vehicule {

    public Voiture(String marque, String modele) {
        super(marque, modele);
    }

    @Override
    public void seDeplacer() {
        System.out.println("La voiture" + this.marque + 
             " " + this.modele + " roule à toute allure !");
    }

}

Et ainsi lors de l’appel :

public class App {

    public static void main(String[] args) {

        Voiture v = new Voiture("Renault", "Clio");
        v.seDeplacer();
    
    }
}

Résultat dans la console :

La méthode redéfinie doit, dans la classe dérivée, porter exactement le même nom que la méthode de base, et avoir exactement les mêmes paramètres, avec le même nombre, les mêmes types et le même ordre. C’est là toute la différence avec la surcharge.

Vous remarquerez sans doute l’annotation @Override précédant la méthode redéfinie. Il s’agit d’une annotation introduite avec Java 5, signalant simplement qu’une méthode est redéfinie. Celle-ci est totalement facultative, cependant son usage est recommandé. Si vous utilisez @Override sur une méthode qui n’est pas redéfinie, une erreur sera signalée à la compilation. Pratique pour détecter quelques fautes (erreur lors de la recopie du nom de la méthode, oubli de spécifier la relation d’héritage dans la déclaration de la classe par exemple).

Nous venons de voir que la méthode redéfinie écrase le comportement de la méthode de base. Comment faire pour, non pas totalement réécrire la méthode, mais bénéficier du comportement de la méthode de base et y adjoindre quelques instructions supplémentaires ? Il suffit simplement d’appeler la méthode de base dans la méthode redéfinie. Comment ? À l’aide du mot-clé super.

super fait référence à la classe mère et permet d’appeler ses méthode. Si l’on utilisait this pour appeler la méthode, nous aurions eu une récursion infinie. De même, vous avez sans doute remarqué que pour définir le constructeur de la classe Voiture, j’ai utilisé super() pour faire référence au constructeur de la classe Vehicule. Allez re-vérifier, je vous attends là 👋.

Ce qui donnerait quelque chose comme cela :

public class Voiture extends Vehicule {

    public Voiture(String marque, String modele) {
        super(marque, modele);
    }

    @Override
    public void seDeplacer() {
        super.seDeplacer();
        System.out.println("Et c'est une voiture !");
    }

}

Voici désormais ce que l’on aurait dans la console lors de l’appel de la méthode seDeplacer() sur une instance de Voiture :

Il est bien entendu possible de redéfinir une méthode plusieurs fois, dans le cas d’une relation d’héritage à plusieurs niveaux (une classe qui en hérite d’une autre, qui elle-même est dérivée, ainsi de suite).

OK … ça va jusqu’ici ? On continue ?

La redéfinition de méthode est souvent liée à la notion d’abstraction. Une classe abstraite est une classe possédant les propriétés suivantes :

  • elle ne peut pas être instanciée ;
  • elle peut posséder des méthodes abstraites.

Bon, pour le premier point, cela semble plutôt clair. Le seul but d’une classe abstraite est d’être héritée (ou dérivée). Par contre pour le deuxième point, cela ne nous avance pas plus que ça …

Qu’est-ce qu’une méthode abstraite ? Il s’agit d’une méthode ne possédant pas de corps, et devant impérativement être redéfinie dans toutes les classes dérivées. Une méthode abstraite ne peut exister que dans une classe abstraite.

Transformons en classe abstraite la classe Vehicule, qui de toute manière ne correspond pas à quelque chose de concret et dont la seule présence dans notre programme s’explique par la volonté de factoriser les comportements communs à tous les véhicules (voitures, motos, avions, etc.). De même, rendons la méthode seDeplacer() abstraite. On utilise pour cela le mot-clé abstract.

public abstract class Vehicule {

    protected String marque;
    protected String modele;

    public Vehicule(String marque, String modele) {
        this.marque = marque;
        this.modele = modele;
    }

    public abstract void seDeplacer();
}

À partir de là, nous sommes contraints de redéfinir la méthode seDeplacer() dans la classe Voiture. Bon ça tombe bien, c’est déjà fait … par contre, il faudra retirer l’appel à la méthode de base avec le super, car celle-ci n’est désormais plus implémentée ! De plus, si l’on souhaite créer d’autres classes qui héritent de Vehicule, il faudra également redéfinir la méthode seDeplacer() dans chacune d’entre elles. Si tel n’est pas le cas, le compilateur lèvera une erreur.

public class Voiture extends Vehicule {

    public Voiture(String marque, String modele) {
        super(marque, modele);
    }

    @Override
    public void seDeplacer() {
        System.out.println("La voiture" + this.marque + 
             " " + this.modele + " roule à toute allure !");
    }

}
public class Moto extends Vehicule {

    public Moto(String marque, String modele) {
        super(marque, modele);
    }

    @Override
    public void seDeplacer() {
        System.out.println("La moto " + this.marque + 
             " " + this.modele + " bombarde sur l'autoroute !");
    }

}

Pas tous les langages orientés objet mettent en œuvre ce mécanisme d’abstraction de manière native : il s’agit d’une notion que l’on retrouve plus habituellement dans des langages strictement typés tels que Java ou C# par exemple. Cependant, la plupart des langages orientés objet permettent, d’une manière ou d’une autre, de créer des classes abstraites.

En Java spécifiquement, il n’est pas possible de mettre en place un héritage multiple, cependant le langage propose les interfaces, qui sont l’équivalent de classes 100% abstraites, et constituent un type à part entière. Ainsi, toutes les méthodes déclarées dans une interface doivent être redéfinies dans les classes qui les implémentent (ce qui n’est plus totalement vrai depuis Java 8 où l’on peut associer des comportements par défaut aux méthodes d’une interface).

En résumé …

La redéfinition de méthode désigne le fait, dans une classe dérivée, de réécrire le code d’une méthode héritée depuis la classe de base, en conservant strictement le nom de celle-ci, ainsi que l’ordre, le nombre et le type de ses paramètres. La méthode redéfinie peut invoquer le comportement défini initialement dans la méthode de base (à l’aide du mot-clé super en Java).

Il s’agit d’une notion purement objet, ainsi on ne retrouve pas la redéfinition de méthode dans les langages de programmation ne proposant pas … de méthode.

Pour conclure

  • Méthode surchargée : plusieurs fois la même méthode dans une même classe, avec le même nom mais avec des paramètres différents.
  • Méthode redéfinie : une méthode dans une classe fille qui porte le même nom et possède les mêmes paramètres qu’une méthode de sa classe mère.

Certains langages implémentent uniquement la surcharge (langages non objet, surchage de fonction), d’autres implémentent uniquement la redéfinition (langages objet au typage strict), beaucoup implémentent les deux. C’est notamment le cas de Java, C#, C++ ou PHP pour ne citer que ceux-là.

Est-il possible d’avoir une méthode à la fois redéfinie et à la fois surchargée ? Oui totalement. Exemple avec la classe Voiture :

public class Voiture extends Vehicule {

    public Voiture(String marque, String modele) {
        super(marque, modele);
    }

    @Override
    public void seDeplacer() {
        // Méthode redéfinie depuis la classe Vehicule
        System.out.println("La voiture" + this.marque + 
             " " + this.modele + " roule à toute allure !");
    }

    public void seDeplacer(int vitesse) {
        // Surcharge de la méthode avec un paramètre de type int
        System.out.println("La voiture" + this.marque + 
             " " + this.modele + " roule à " + vitesse + " km/h !");
    }

}

Plus globalement, toutes les notions évoquées dans cet article ont trait à une notion beaucoup plus globale qu’est le polymorphisme. On abordera ce thème plus en détail dans un prochain article.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *