Modifier

Conception orientée objet

Les concepts fondamentaux

Lorsqu'on fait de la POO, les maîtres mots sont abstraction, encapsulation et polymorphisme.

L'encapsulation

L'encapsulation consiste à réunir au sein d'une même enveloppe (classe) les données (attributs) et les comportements (méthodes) liés à une même thématique. On crée ainsi une boîte noire dont le contenu est inaccessible si on ne passe pas par les méthodes dédiées.

Protéger l'accès aux données d'une classe permet à cette dernière d'évoluer plus simplement. En effet, l'utilisation d'un attribut ou d'une méthode ailleurs que dans la classe propriétaire crée une dépendance vis à vis de cette classe. La présence de ces dépendances figent le code de la classe et l'empêche de changer. Il faut alors défaire les dépendances avant de pouvoir de nouveau faire évoluer la classe.

Le fait de regrouper des données et des comportements basées sur la même thématique permettent d'appliquer le principe S de SOLID (Single Responsability Principle). Ce principe explique qu'une classe ne doit avoir qu'une seule raison de changer. En suivant cette règle, si nous détectons qu'une classe fait trop de choses différentes, il peut être nécessaire de la diviser en plusieurs classes.

Prenons l'exemple d'une classe Time qui permet de gérer l'heure :

public class Time {
    private int hours;
    private int minutes;
    private int seconds;

    Time(int hours, int minutes, int seconds) {
        this.hours = hours;
        this.minutes = minutes;
        this.seconds = seconds;
    }

    public void addSeconds(int seconds) {
        this.seconds += seconds;
        if (this.seconds >59) {
            this.minutes += (this.seconds / 60);
            this.seconds = (this.seconds % 60);
        }
        ...
}

....

Time t = new Time(10, 5, 25);
t.addSeconds(250);

Dans cet exemple, la méthode addSeconds permet d'ajouter des secondes tout en respectant les règles métiers propre à la gestion du temps. Lorsque le nombre de secondes dépasse 59, il faut ajouter une ou plusieurs minutes.

Grâce à l'encapsulation, les règles métiers (ici, c'est l'ajout des secondes) sont respectées car on ne peut pas faire autrement. De plus, l'accès aux variables hours, minutes et secondes sont protégées.

Imaginez si les attributs n'étaient pas private et que l'on avait un peu partout dans le code les appels suivants :

Time t = new Time(10, 5, 25);
...
if (t.hours == 15) {
...
}

Le jour où l'on souhaite changer l'implémentation de Time (en utilisant un timestamp par exemple), il faudrait revoir toutes les utilisations de t.hours (avec un timestamp, t.hours disparaitrait).

L'encapsulation protège les implémentations et permet de réduire les dépendances.

L'abstraction

L'abstraction est le principe qui consiste à cacher le détail de l'implémentation. Autrement dit, cela signifie que l'on sait ce que le code fait, mais on ne sait pas comment il le fait.

Cela a plusieurs avantages.

D'une part, cela autorise le développeur à modifier l'implémentation quand il le souhaite. D'autre part, cela permet de simplifier le code. En effet, derrière une ligne de code abstraite peut se cacher une implémentation plus complexe et beaucoup plus longues en terme de code.

Ces deux avantages permettent de rendre le code plus simple à maintenir et plus robuste.

Mais avec ces avantages, l'abstraction vient aussi avec son lot de contraintes :

Voici un petit exemple d'abstraction. Imaginons que l'on a un catalogue de film et on souhaite trouver tous les films ayant certains mots clés dans leurs titres.

public List<Movie> search(String filter) {
    List<Movie> result = new ArrayList<Movie>();

    for (Movie movie : this.movies) {
        boolean allWordsFoundInTitle = true;
        for (String word : filter.split(",")) {
            if (! movie.getTitle().contains(word) {
                 allWordsFoundInTitle = false;
            }
        }
        if (allWordsFoundInTitle) {
            result.add(movie);
        }
    }
    return result;
}

Avec l'abstraction, on pourrait avoir le code suivant :

public List<Movie> search(String filter) {
    List<Movie> result = new ArrayList<Movie>();

    for (Movie movie : this.movies) {
        if (this.movieContainsAllWords(movie, filter) {
            result.add(movie);
        }
    }
    return result;
}
public boolean movieContainsAllWords(Movie movie, String filter) {
    for (String word : this.listWords(filter)) {
       if (! movie.contains(word) {
            return false;
        }
    }
    return true;
}

public String[] listWords(String filter) {
    return filter.split(",");
}

public class Movie {
    public boolean contains(word) {
        return this.getTitle().contains(word);
    }
}

La méthode movieContainsAllWords permet d'abstraire la manière de vérifier si un film contient tous les mots de la recherche. On constate que c'est plus verbeux, mais le code est mieux organisé. Chaque méthode a une intention bien précise (S de SOLID). Si jamais on doit faire évoluer le code, par exemple, en gérant le fait que les mots de la recherche n'ont pas besoin de respecter la casse, il est plus simple de détecter l'endroit où modifier cela. Dans ce cas précis, la modification se porte au niveau de la méthode contains de Movie.

public class Movie {
    public boolean contains(word) {
        return this.getTitle().toLowerCase().contains(word.toLowerCase());
    }
}

Le polymorphisme

Si on étudie l'étymologie du mot polymorphisme, cela signifie en grec plusieurs formes. Cela sous-entend que les objets peuvent avoir plusieurs formes différentes mais qu'on les manipule de la même manière.

Ici, lorsqu'on parle de la forme d'un objet, on parle de son implémentation, c'est à dire son comportement. Et où se trouve le comportement ? Dans l'encapsulation.

Pour manipuler un objet de la même manière qu'un autre, cela veut dire qu'ils ont la même interface. Mais s'ils ont un comportement différent, cela signifie que l'on masque leur implémentation. Ce qui revient à dire que l'on utilise l'abstraction.

On peut donc établir que le polymorphisme est le résultat de l'encapsulation associée à l'abstraction. Quand on utilise le polymorphisme, on utilise des types abstraits qui ont une implémentations différentes (encapsulation).

Si l'on reprend l'exemple précédent, au lieu de travailler sur un type concret Movie, on pourrait travailler sur un type abstrait Media. Les types Movie et Music dérivent de Media.

public List<Media> search(String filter) {
    List<Media> result = new ArrayList<Media>();

    for (Media media : this.medias) {
        if (this.mediaContainsAllWords(media, filter) {
            result.add(media);
        }
    }
    return result;
}
public boolean mediaContainsAllWords(Media media, String filter) {
    for (String word : this.listWords(filter)) {
       if (! media.contains(word) {
            return false;
        }
    }
    return true;
}

public String[] listWords(String filter) {
    return filter.split(",");
}

public class Media {
    public boolean contains(word) {
        return this.getTitle().contains(word);
    }
}

En remplaçant Movie par Media, on peut désormais rechercher toutes sortes de média sans se soucier que ce soit des films ou des musiques.

Le polymorphisme permet de réutiliser le même code avec des objets ayant des comportements différents (mais similaires) sans avoir à dupliquer le code.

Conclusion

Ces trois notions sont fondamentales dans la compréhension de la POO. Pour pouvoir utiliser ces concepts, les différents langages mettent à notre dispositions des outils.

Les outils pour pratiquer la Programmation Oientée Objet

Les classes

Les classes correspondent à l'enveloppe dont je parlais plus haut. C'est un élément de programmation qui regroupe d'autres éléments de programmation (attributs, propriétés et méthodes). Ces autres éléments sont encapsulés dans la classe. Le système de classe est le principal outil qui permet de mettre en place l'encapsulation. En effet, il est courant de regrouper au sein d'une même classe les éléments liés au même concept.

Exemple de classe en Python

class People:
    # constructor
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    def say_hello(self):
        print("hello, i'm {} {}".format(self.first_name, self.last_name))

Exemple de classe en Java public class People { private String firstName; private String lastName;

    // constructor
    public People(firstName, lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public void sayHello() {
        System.out.println(String.format("hello, i'm %s %s", this.firstName, this.lastName));
    }
}

L'héritage

L'héritage est un mécanisme présent dans la plupart des langages orientés objet. Il permet à une classe de récupérer une partie (et parfois la totalité) des comportements et données encapsulés dans une autre classe. On dit donc que cette classe (la première) hérite de l'autre classe (la seconde). Cette seconde classe est appelée la classe parent.

L'héritage est aussi l'une des techniques qui permet de mettre en oeuvre le polymorphisme. De manière générale, les instances d'une classe A qui hérite d'une autre classe B sont interchangeables avec les instances de la classe B.

Exemple d'héritage en Python

class Parent:
    pass

class Child(Parent):
    pass

Exemple d'héritage en Java public class Parent {}

public class Child extends Parent {}

Les interfaces

Le système d'interface permet d'imposer à une classe de respecter un contrat. Ce dernier correspond à un ensemble de méthodes publiques que la classe doit implémenter. Ce système nous permet d'exploiter pleinement les notions principales de la POO (abstraction, encapsulation et polymorphisme).

En effet, lorsqu'on utilise une variable dont le type est une interface, on travaille avec une abstraction pure car on n'a aucune connaissance de l'implémentation. Et c'est l'implémentation qui contient le détail. Ce détail est encapsulé dans la classe qui implémente l'interface. Et enfin, deux objets qui implémentent la même interface sont interchangeables (le polymorphisme).

Enfin, il faut savoir qu'une classe peut implémenter plusieurs interfaces.

Python n'intègre pas la notion d'interface. A la place, il propose le duck typing (si ça vole comme un canard et que ça cancanne comme un canard, alors c'est un canard). On a donc toutes les particularités du système d'interface, sauf la notion de contrat. Sans passer par un artifice (que nous verrons dans la partie suivante), il n'est pas possible d'obliger une classe à implémenter certaines méthodes.

Quant à Java, il fonctionne énormément avec les interfaces. Il est donc vital de les maîtriser dans ce langage.

Voici un exemple de code qui permet de définir une interface en Java :

public interface Duke {
    public void fly();
    public void quack(); // cancaner en français
}

Et pour l'implémenter :

public class RealDuke implements Duke {
    public void fly() {
        ...
    }

    public void quack() {
        ...
    }
}

Les classes abstraites

Une classe abstraite est une classe qui ne peut pas être instanciée et qui doit être dérivée. Elle ne peut pas être instanciée car elle possède au moins une méthode abstraite (publique ou protégée). Ce sont les classes enfants qui devront implémenter cette méthode. Si une classe enfant ne le fait pas, elle est considérée elle aussi comme une classe abstraite.

L'intérêt de ces classes est d'obliger le développeur à implémenter le comportement de la classe en fonction de ses besoins. On peut considérer cela comme une spécialisation de la classe. Plusieurs designs pattern se basent sur les classes abstraites, en particulier le patron de méthode. La classe abstraite met en place un algorithme et permet d'abstraire certaines de ses parties.

Dans certains cas, une classe abstraite peut jouer le rôle d'une interface. En effet, il est possible de créer une classe avec seulement des méthodes abstraites et aucune implémentation. Les classes enfants devront implémenter chacune de ces méthodes de la même manière qu'une interface. En Java, on ne peut hériter que d'une seule classe, ce principe ne permet donc pas de se substituer aux interfaces (une classe peut implémenter plusieurs interfaces). Cependant, en Python, l'héritage multiple est possible et le module abc permet de créer une classe abstraite. En le détournant un peu de son utilisation de base, il est ainsi possible de créer des interfaces.

Voici un exemple de code en Java permettant de créer une classe abstraite :

public abstract class Test {
    public abstract void test(); // Cette méthode est abstraite
}

Et en Python :

# Cette syntaxe fonctionne à partir de Python 3.4
from abc import ABC, abstractmethod
class Abstract(ABC):
    @abstractmethod
    def foo(self):
        pass

Comment travailler avec la POO

Nous connaissons maintenant les principaux outils qui nous permettent d'utiliser les trois concepts fondamentaux (abstraction, encapsulation et polymorphisme), nous devons maintenant apprendre quand et comment nous en servir.

Quand utiliser le polymorphisme

Lorsqu'on découvre la POO, il n'est pas facile de déterminer à quel moment on a besoin d'utiliser le polymorphisme. Les autres concepts sont plus simples à mettre en oeuvre.

L'encapsulation est en général le premier concept que l'on intègre. Les débutants tombent dans le piège de tout mettre dans des classes, mais au final, cela montre qu'ils ont compris le fonctionnement. Les classes sont des boîtes noires qui encapsulent les données et les règles comportementales. Les différentes variables sont regroupées par thématique (la classe) et la visilibité (public, protégée ou globales) permet d'en restreindre l'accès en fonction des besoins.

L'abstraction arrive en second. Même lorsqu'on fait du procédural, on utilise la notion d'abstraction. En effet, le simple fait de créer une méthode ajoute un niveau d'abstraction au code. On cache derrière le nom d'une fonction l'implémentation qui permet d'exécuter la fonctionnalité.

Mais en ce qui concerne le polymorphisme, c'est une notion que l'on met beaucoup plus de temps à maîtriser. Certains développeurs pensent connaître la POO sans utiliser une seule fois le polymorphisme dans leur programme. En fait, ils utilisent principalement l'encapsulation et un peu l'abstraction. C'est malheureux car le polymorphisme permet de réduire la quantité de code mais aussi de de le simplifier.

Mais revenons à notre question de base, quand doit-on utiliser le polymorphisme ?

Quand c'est évident !

La réponse la plus évidente, c'est justement quand c'est évident. Si vous travaillez avec des éléments qui ont une nature similaire mais qui ont des comportements différents, il y a de forte chance que le polymorphisme soit le meilleur choix possible.

Imaginons que l'on travaille sur un jeu vidéo. Le jeu vidéo contient des personnages et ces personnages peuvent être des chevaliers, des magiciens, etc... (désolé pour cet exemple qui fait très cliché et qui montre que j'aime les jeux videos, mais c'est l'exemple le plus simple que j'ai trouvé). On utilisera le polymorphisme pour manipuler les différents personnages, sans se soucier si ce sont des chevaliers ou des magiciens). L'utilisation est évidente.

Mais attention, ce n'est pas parce que l'on sait qu'il faut utiliser le polymorphisme que l'organisation des classes va être évident. Dans l'exemple précédent, on peut se demander si on doit utiliser une classe ou une interface pour représenter notre Personnage. Peut-être va-t-on utiliser la composition pour le comportement de chaque personnage ? Ou bien l'héritage ? C'est une question difficile à répondre. Je vous invite à lire l'article suivant (en 5 parties) si vous souhaitez avoir plus de détail sur ce genre de problématique (en Anglais) : https://ericlippert.com/2015/04/27/wizards-and-warriors-part-one/.

Lors du refactoring

Si on n'utilise pas le polymorphisme la première fois que l'on écrit un code, alors, on ne peut l'écrire qu'après coup, c'est à dire à l'étape du refactoring. Cette activité consiste à revoir le code existant pour l'améliorer sans ajouter de fonctionnalité. L'une des étapes du refactoring consiste à enlever les duplications de code.

Le polymorphisme est utile lorsque l'on rencontre souvent des instructions conditionnelles autour de la même thématique.

Prenons l'exemple suivant (tiré du site https://refactoring.guru/replace-conditional-with-polymorphism) :

class Bird {
    //...
    double getSpeed() {
        switch (type) {
            case EUROPEAN:
                return getBaseSpeed();
            case AFRICAN:
                return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
            case NORWEGIAN_BLUE:
                return (isNailed) ? 0 : getBaseSpeed(voltage);
        }
        throw new RuntimeException("Should be unreachable");
    }

    double getColor() {
        switch (type) {
            case EUROPEAN:
                return "red";
            case AFRICAN:
                return "green";
            case NORWEGIAN_BLUE:
                return "blue";
        }
        throw new RuntimeException("Should be unreachable");
    }
}
// Somewhere in client code
Bird bird = new Bird(type);
double speed = bird.getSpeed();
String color = bird.getColor();

Après refactoring, voici le code :

abstract class Bird {
    //...
    abstract double getSpeed();
    abstract String getColor();
}

class European extends Bird {
    double getSpeed() {
        return getBaseSpeed();
    }

    String getColor() {
        return "red";
    }
}

class African extends Bird {
    double getSpeed() {
        return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
    }

    String getColor() {
        return "green";
    }
}

class NorwegianBlue extends Bird {
    double getSpeed() {
        return (isNailed) ? 0 : getBaseSpeed(voltage);
    }

    String getColor() {
        return "blue";
    }
}

Bird createBird(int type) {
        switch (type) {
            case EUROPEAN:
                return new European();
            case AFRICAN:
                return new African();
            case NORWEGIAN_BLUE:
                return new NorwegianBlue();
    }
    throw new RuntimeException("Should be unreachable");
}

// Somewhere in client code
Bird bird = createBird(type)
double speed = bird.getSpeed();
String color = bird.getColor();

La première reflexion qui doit vous venir à l'esprit, c'est que le code est plus long. C'est vrai, l'utilisation de plusieurs classes est beaucoup plus verbeux que l'utilisation d'une seule classe. Si on s'arrête sur ce seul constat, la première méthode est plus intéressante. Mais la seconde forme possède un avantage majeur, c'est qu'il n'y a plus qu'un seul switch.

Mais je profite de cet exemple pour attirer votre attention sur les avantages de la seconde méthodes :

On peut faire le même constat avec l'instruction if.

Rendez votre code SOLID

A lire :

SRP - Single Responsibility Principle (Responsabilité unique)

Une classe, une fonction ou une méthode doit avoir une et une seule responsabilité.

L'un des auteurs de SOLID (Robert C. Martin aka Uncle Bob) l'a aussi exprimé sous la forme : Une classe, une fonction ou une méthode ne doit avoir qu'une seule raison de changer.

L'objectif du polymorphisme est de pouvoir modifier facilement le comportement en remplaçant une classe par un de ses dérivé. Si une classe fait plusieurs choses, cela signifie qu'elle possède plusieurs comportements, il n'est donc plus possible de remplacer un seul comportement, il faut tous les remplacer. Une solution a ce problème est de scinder la classe en plusieurs classes pour que chacune n'ait qu'une seule responsabilité.

OCP - Open / Closed Principle (Ouvert/fermé)

Une classe doit être ouverte à l'extension, mais fermée à la modification

Sauf s'il faut corriger un bug, il est préférable de ne pas avoir à modifier le comportement d'une classe. En effet, des dépendances avec celle-ci ont été créées à chaque fois que celle classe a été utilisée. Le changement de comportement risque d'avoir des effets de bord imprévisible avec l'existant.

C'est pourquoi il faut essayer de prévoir le changement de comportement non pas en la modifiant mais en lui permettant d'être étendue, soit en utilisant l'héritage, soit en lui passant le nouveau comportement en paramètre.

LSP - Liskov Substitution Principle (Substitution de Liskov)

Une instance de type T doit pouvoir être remplacée par une instance de type G, tel que G sous-type de T, sans que cela ne modifie la cohérence du programme.

Cette règle est assez complexe à comprendre.

D'une part, les deux instances doivent avoir exactement la même interface (signature des méthodes, etc...) et les valeurs retournées doivent être du même type. D'autre part, l'une des deux instances ne doit pas avoir un comportement imprévu par rapport aux autres instances, comme par exemple renvoyer une exception là où on n'en attend pas.

ISP - Interface Segregation Principle (Ségrégation des interfaces)

Préférer plusieurs interfaces spécifiques pour chaque client plutôt qu'une seule interface générale

Ce principe ressemble au SRP, sauf qu'il s'applique aux interfaces. Lorsqu'on utilise une interface, il faut qu'elle ne propose que les méthodes nécessaires. Si elle en propose trop, il y a des dépendances qui se créent. En cas de changement apportée à l'interface, il faut alors apporter des changement partout où elle est utilisée.

DIP - Dependency Inversion Principle (Inversion des dépendances)

Il faut dépendre des abstractions, pas des implémentations

Si vous devez retenir un principe, c'est celui-ci. L'inversion des dépendances permet à votre code d'évoluer en toute sérénité et d'être robuste face au changement.

Ce principe explique que vous devez utiliser le moins possible les implémentations dans vos classes. En effet, contrairement à une abstraction, une implémentation est figée et empêche d'utiliser le polymorphisme. De ce fait, votre classe dépend de cette implémentation. Ce qui signifie deux choses :

Pour résoudre ce problème, on injecte les dépendances au moyen d'une interface. L'implémentation que vous utilisez doit implémenter l'interface. Si jamais l'implémentation change mais pas l'interface, tout va bien, et si vous souhaitez modifier le comportement, vous pouvez créer une nouvelle implémentation.