5 concepts fondamentaux de la POO.


On entend beaucoup parler de programmation fonctionnelle (PF) ces temps-ci, les développeurs sont hypés et les langages de programmation traditionnels évoluent de plus en plus dans ce sens.

C’est une bonne chose, car il est vrai qu’elle apporte beaucoup de très bonnes pratiques pour construire des applications évolutives, maintenables, et pérennes.

Mais du coup, est-ce que ça veut dire que la PF va tuer la POO ?

En effet, certaines personnes profitent de cet engouement pour discréditer ce qu’ils utilisaient avant.

On entend dire que la POO n’est qu’une abomination sans nom, un gloubi-boulga de designs farfelus et d’anti-patterns qui a hanté les développeurs pendant des années, et que la PF va enterrer à tout jamais.

Et c’est vrai qu’en lisant ça constamment sur les forums de “””l’élite de la programmation”””, on peut se poser la question…

Les outils que j’utilise sont-ils si mauvais ? M’a-t-on menti pendant toutes ces années ? Est-ce que je vais me retrouver à la rue demain si je ne m’adapte pas ?

Mais je vous rassure tout de suite, tout ça, ce ne sont que des conneries.

J'aime bien cette image, elle est très représentative de l'élite de la programmation.

Non seulement la POO à encore de belles décennies devant elle. Mais en plus , elle est très loin d’être aussi mauvaise que ses détracteurs le prétendent.

Elle est toujours aussi puissante et reste le choix favori de beaucoup d’architectes qui l’utilisent pour designer des applications encore maintenables après 20 ans.

Car en réalité, les problèmes de la POO viennent surtout du fait que ses principes fondamentaux ne sont souvent pas respectés pour des raisons variées qui méritent un article complet à elles toutes seules.

Mais on retrouve exactement le même problème pour tous les autres paradigmes, à commencer par la PF elle-même.

Généralement, il s’agit plus d’une question de spécification des langages. Ceux qui offrent beaucoup de libertés ont tendance à être plus difficiles à maintenir sur le long terme.

Mais rien d’exclusif à la POO, qui peut être un paradigme absolument surpuissant, à condition de maîtriser parfaitement ses concepts de base.

C’est pourquoi dans cet article, je vais repasser sur les 5 principes les plus importants, et montrer comment ils permettent de construire des applications sécurisées, maintenables et durables, tout en limitant la dette technique.

Et si vous les connaissez déjà, une piqûre de rappel ne fait pas de mal. Qui sait, vous pourriez peut-être apprendre des choses qui vous ont échappées durant des années 😉

Le code complet et commenté intégrant tous les concepts de cet article est disponible gratuitement ici.


Encapsulation

L’encapsulation est le fait de grouper des données (champs) et comportements (méthodes) qui les manipulent en un point.

Elle permet d’organiser le code et de restreindre l’accès à certaines portions depuis l’extérieur de la capsule.

class Chef
{
    // Propriétés relative aux caractèristiques du chef.
    // Publiques car elles pouront être pertinentes hors de la classes.
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    public int Experience { get; private set; }

    // Propriétés relative à l'expérience du chef.
    // Privées car elles ne concenent que le fonctionnement interne de la classe, et ne doivent pas être accessible de l'extérieur.
    private int ExperienceNeeded { get; set; }

    // Méthode publique : règles métier.
    public Chef(string firstname, string lastname)
    {
        FirstName = firstname;
        LastName = lastname;
        Level = 1;
    }
    
    public void Practice(Meal meal)
    {
        int exp = meal switch 
        {
            Rosbif => 60,
            Pizza => 30,
            _ => 10
        };

        Train(exp);
    }

    // Méthode privée : fonctionnement interne uniquement
    private void Train(int experience)
    {
        Experience += experience;
    }
}

Contrairement à ce que disent les détracteurs de la POO, l’encapsulation n’est pas un moyen de mélanger les données et les comportements, mais de restreindre certains comportements aux données qu’ils manipulent.

Cela a pour effet de rendre les programmes plus sécurisés, car on s’assure que les données ne pourront jamais être modifiées de manière imprévue.

Et en plus de ça, elle permet de créer des codebases mieux organisées, car chaque chose est à sa place. On s’assure qu’une méthode définie en Italie ne sera pas utilisée au Kazakhstan.

Cela signifie moins de changements d’état bizarres, et donc des bugs plus faciles à traquer, ce qui rend les programmes beaucoup plus simples à maintenir, mais aussi à comprendre.

Chef chef = new Chef("Pascal", "MOFFO");

Console.WriteLine(chef.FirstName);  // "Pascal"
Console.WriteLine(chef.LastName);   // "MOFFO"
Console.WriteLine(chef.Level);      // 1

Meal rosbif = chef.Cook(new Meal()); 

// Ne compile pas, car Experience est innaccessible en lecture
// Console.WriteLine(chef.Experience);

// Ne compile pas, car FirstName est innaccessible en modifications
// chef.Level == 999;

// Ne compile pas, car Train(int) est innaccessible
// chef.Train(1000000);

Contre-exemple :

Chef chef = new Chef("Pascal", "MOFFO");

Console.WriteLine(chef.FirstName);  // "Pascal"
Console.WriteLine(chef.LastName);   // "MOFFO"
Console.WriteLine(chef.Level);      // 1

Meal rosbif = chef.Cook(new Meal()); 

// Tricherie !!!
Console.WriteLine(chef.Experience);
chef.Experience == 9999999;
chef.Train(1000000);
Typiquement ce qu'il peut arriver lorsque tous les membres de la classe sont publics.

Voici quelques lignes directrices pour respecter le principe d’encapsulation :

  • Faire un maximum de méthodes/champs privés possible, c’est le choix par défaut.
  • Utiliser au maximum les variables locales, et n’utilisez les champs que s’ils sont publics ou s’ils agissent en tant que variables globales à la classe (toujours privés).
  • Faire des accesseurs/mutateurs s’il y a la moindre restriction concernant un champ : lecture seule, risque d’instabilités, comportement spécifique…
  • Utiliser astucieusement la composition, l’héritage, les interfaces et les méthodes statiques. Ne transpirez pas, on y arrive !

Composition

C’est le fait d’avoir une instance de classe dans une autre, et ainsi permettre aux deux d’interagir ensemble.

Ce n’est pas sans rappeler le principe de clefs étrangères des bases de données, et c’est justement le design sur lequel il faut s’appuyer quand on fait de la POO.

class Meal
{
    public bool IsCooked { get; protected set; }

    public Chef TheChef { get; set; }

    public Meal(Chef chef) 
    { 
        TheChef = chef;
        IsCooked = false;
    }

    public Meal Cook()
    {
        if(!IsCooked) 
        {
            TheChef.Practice(this);
            IsCooked = true;
        }
        
        return this;
    }
}

Dans l’absolu, la composition devrait être le seul moyen de faire communiquer des objets entre eux.

C’est un principe simple, puissant, facile à traquer et à debugger, qui respecte le principe d’encapsulation, et qui marche quasiment à chaque fois, car les objets sont quasiment toujours des types références.

Chef chef = new Chef("Pascal", "MOFFO");

Rosbif rosbif = new Rosbif(chef, 400);
Meal meal = new Meal(chef);

// +60 EXP pour le chef
rosbif.Cook();
// +10 EXP pour le chef
meal.Cook();

Console.WriteLine(chef.Experience); // 70
Console.WriteLine(rosbif.IsCooked); // true
Console.WriteLine(meal.IsCooked);   // true

Contre-exemple :

Chef chef = new Chef("Pascal", "MOFFO");

Rosbif rosbif = new Rosbif(400);
Meal meal = new Meal();

rosbif.Cook(chef);
meal.Cook(chef);
Ici, on ne passe qu'un seul paramètre à la méthode Cook, donc c'est tolérable. Mais imaginez s'il y en avait 9...

De manière générale, il faut se méfier lorsque des objets sont passés en paramètres de méthodes. Très souvent, il s’agit d’une mauvaise pratique qui peut être remplacée par de la composition.

Et encore une fois, consommez local ! S’il s’agit d’un objet utilisé pour une seule méthode et qui ne sort pas de ce cadre, construisez le directement dans la méthode. Pas besoin de composition.


Héritage

C’est le fait de baser la définition d’une classe (fille) sur une autre (mère), afin d’en récupérer les membres (champs et méthodes).

// Hérite de Meal, et possède donc un Chef
class Pizza : Meal
{
    public string Pate { get; }
    public string Base { get; }

    public Pizza(Chef chef, string pate, string pizzaBase) : base(chef)
    {
        Pate = pate;
        Base = pizzaBase;
    }
}

Beaucoup de personnes se servent de l’héritage comme un moyen de limiter la duplication de code. Mais en réalité, il ne s’agit que d’un effet secondaire anecdotique du VRAI intérêt de l’héritage, puisque 90% du temps, il vaut mieux utiliser la composition.

En réalité, le but ultime de l’héritage est de faire du polymorphisme : un procédé qui permet d’offrir une même interface de programmation pour des types différents.

Ce type de polymorphisme est particulièrement efficace quand vous voulez regrouper plusieurs objets de types différents dans un même tableau (par exemple : pour les passer dans un foreach).

Le duo héritage/polymorphisme va permet de définir un type commun à tous ces objets afin de ne plus avoir à faire du cas par cas lors du traitement.

Chef pascal = new Chef("Pascal", "MOFFO");

PizzaChorizo pizzaPaul = new PizzaChorizo(pascal, 100, 1, 1, 125, 100);
PizzaReine pizzaJacques = new PizzaReine(pascal, 1, 150, 100);
PizzaRegina pizzaPierre = new PizzaRegina(pascal, 150, 12, 125);

List<Pizza> allPizzas = new List<Pizza>() { pizzaPaul, pizzaJacques, pizzaPierre };
foreach (Pizza pizza in allPizzas) 
{
    pizza.Cook();
}

Console.WriteLine(pascal.Experience);       // 90
Console.WriteLine(pizzaPaul.IsCooked);      // true
Console.WriteLine(pizzaJacques.IsCooked);   // true
Console.WriteLine(pizzaPierre.IsCooked);    // true

Contre-exemple :

Chef pascal = new Chef("Pascal", "MOFFO");

PizzaChorizo pizzaPaul = new PizzaChorizo(pascal, 100, 1, 1, 125, 100);
PizzaReine pizzaJacques = new PizzaReine(pascal, 1, 150, 100);
PizzaRegina pizzaPierre = new PizzaRegina(pascal, 150, 12, 125);

pizzaPaul.Cook();
pizzaJacques.Cook();
pizzaPierre.Cook();
On fait du cas par cas. Ici, ça passe, car il n'y a que 3 pizzas, mais imaginez si Pascal devait en faire 400...

C’est un principe qui est au cœur de tous les frameworks. Les créateurs ne savent pas quelles classes vont hériter de celles du framework, mais ce n’est pas grave, car grâce au polymorphisme, elles pourront toutes être traitées de la même manière.

// ASP.NET Web API
public class PizzaController : ApiController
{
	//...
}
En ASP.NET, tous les contrôleurs héritent des classes Controller ou ApiController, ce qui permet au framework de les gérer, peut importe le code que l’on met dedans.

Ce n’est pas la seule façon de faire du polymorphisme en POO, on pourrait notamment citer la surcharge ou la généricité. Mais le sujet est si vaste qu’il mérite son article à lui tout seul. Donc si ça vous intéresse, faites-le-moi savoir dans les commentaires tout en bas.


Interface

Il s’agit d’un ensemble de signatures de méthodes sans implémentation, et les classes liées à cette interface devront obligatoirement implémenter ces méthodes.

interface IChef
{
    string FirstName { get; }
    string LastName { get; }
    int Experience { get; }

    void Practice(Meal meal);
}

Il s’agit d’un concept vraiment mystérieux de la POO que peu de développeur utilisent à son plein potentiel (hormis pour se la péter). Pourtant, elles sont extrêmement pratiques et servent à faire plein de choses !

Mais il y en a une dont j’aimerais parler en particulier : le principe d’obligation contractuelle.

Lorsque vous travailler sur l’architecture d’une application, vous allez établir les fondations de la codebase : elle contiendra telles classes, avec telles méthodes qui serviront à faire tels trucs.

Un diagramme de classe (UML), très pratique lorsque l'on prépare l'architecture d'une application.

Il faut savoir qu’une architecture logicielle est une vision idéaliste d’une codebase, avant que les erreurs, maladresses, raccourcis et autres sources de dette technique viennent pourrir le tout.

Et une interface va servir à forcer cette vision idéaliste en guidant la création des classes, d’où l’intérêt de les écrire en tout début de projet.

En d’autres termes, il s’agit d’un contrat obligeant les développeurs à écrire les classes de la manière dont l’architecte les à prévues afin de limiter les écarts d’architecture, et donc la dette technique. Je parle plus en détail de ce sujet dans cet article.

Bien sûr, les interfaces n’empêchent pas de coder n’importe comment, mais elles ont le mérite :

  • De forcer les développeurs à respecter le principe d’encapsulation au maximum, en faisant apparaître uniquement les méthodes qui seront accessibles depuis l’extérieur.
  • D’empêcher les développeurs d’utiliser les objets d’une manière imprévue.
  • De donner une ligne directrice en indiquant explicitement les fonctionnalités à développer.
  • De documenter le code, car les interfaces contiennent théoriquement toutes les règles métier.
  • De normaliser le nom des API, car plusieurs classes très similaires peuvent implémenter la même interface (exemple : IEnumerable en C#).
  • À séparer correctement les règles métier, car une seule classe peut implémenter plusieurs interfaces.
IChef pascal = new Chef("Pascal", "MOFFO");
Une interface s'utilise à peu près de la même manière qu'une classe. Elle n'a juste pas de champs (accesseurs/mutateurs seulement), ni de constructeurs.

Contrairement à ce que l’on pourrait penser, les interfaces ne sont pas pertinentes uniquement lorsqu’on est en équipe. La dette technique et les écarts d’architecture existent même sur les projets solos, en particulier chez les développeurs peu expérimentés.

Ainsi, pour votre prochain projet, je vous invite vivement à écrire vos interfaces avant de commencer à coder. Les résultats ne seront pas visibles au bout d’une semaine, mais je vous garantis qu’au bout d’un ou deux mois, vous n’allez plus pouvoir vous en passer ! ❤️

Encore une fois, le sujet est très vaste, donc si un article complet sur les interfaces vous intéresse, exprimez-vous dans les commentaires.


Méthodes statiques

Il s’agit de méthodes qui ne nécessitent pas d’instance de classe pour être utilisées. Ce ne sont ni plus ni moins que des fonctions.

Les méthodes statiques servent à plusieurs choses, mais principalement, elles permettent de créer des comportements génériques, utilisables partout dans le programme.

public static class UnitConvert
{
    public static double PoundsToGrams(double pounds) => pounds * 453.592;
}
Mais c’est une hérésie, ça déroge complètement au principe d’encapsulation ! Au bûcher !!!

Calmez-vous, je m’explique. L’intérêt des méthodes statiques est uniquement de contenir des suites d’instructions classiques afin de ne pas avoir à les répéter (DRY).

Elles ne viennent jamais changer l’état du programme ou provoquer des effets secondaires, elles se contentent uniquement de prendre des paramètres d’entrée, effectuer des traitements et sortir un résultat.

Ainsi, elles ne dérogent pas au principe d’encapsulation, car elles n’ont absolument aucun impact sur le programme, hormis rendre le code plus propre et éviter la duplication de code.

Cela ne vous rappelle rien ? C’est exactement le même principe que les fonctions pures en PF. Et oui, POO et PF ne sont pas de vieux ennemis de l’après-guerre, ils peuvent très bien fonctionner ensemble.

Tirer le meilleur des deux paradigmes, c’est ça le vrai succès !

IChef pascal = new Chef("Pascal", "MOFFO");

// Poids en livres
double meatQuantity = 1.2; 
Rosbif rosbif = new Rosbif(pascal, UnitConvert.PoundsToGrams(meatQuantity));

Console.WriteLine(rosbif.Weight);   // 544

Contre-exemples :

IChef pascal = new Chef("Pascal", "MOFFO");

// Poids en livres
double meatQuantity = 1.2; 
Rosbif rosbif = new Rosbif(pascal, meatQuantity);

rosbif.WeightPoundsToGrams();
Le comportement est trop générique pour être encapsulé. Par exemple, on peut très bien envisager de l'utiliser pour d'autres plats, voir complètement autre chose.
IChef pascal = new Chef("Pascal", "MOFFO");

// Poids en livres
double meatQuantity = 1.2; 
Rosbif rosbif = new Rosbif(pascal, meatQuantity * 453.592);
Le "* 453.592" devra être dupliqué à chaque fois que l'on veut faire une conversion en grammes, donc irrespect du principe DRY.

J’explique plus en détail la manière d’implémenter les concepts de la PF en POO dans cet article. Allez le lire, vous ne le regretterez pas !

Pour rappel, le code source de cet article complet et documenté est disponible ici, je vous conseille d’aller y jeter un coup d’œil pour plus de détails.


Voilà, seulement quelques principes de bases, et pourtant, on a déjà de la matière pour construire des applications bien robustes.

Et je n’ai parlé que de 5 pauvres concepts. J’aurais pu en énumérer beaucoup d’autres, ou parler des patrons de conception, ces fameux modèles qui permettent de designer des applications plus propres et plus maintenables (on y reviendra).

Mais encore une fois, le problème ne vient pas de la POO, mais des développeurs qui implémentent mal ces concepts. D’où l’intérêt d’être parfaitement bien formé à leur sujet !

Connaître, comprendre le fonctionnement et l’intérêt, et savoir implémenter ces concepts sont des compétences fondamentales pour devenir un développeur ultra-efficace, et c’est la raison pour laquelle ce blog existe.

Ainsi, si vous avez des difficultés avec certains concepts ou que vous voulez que j’en développe d’autres dans un futur article, laissez moi un commentaire juste en dessous, je lis et réponds à toutes les questions et demandes !

Quant à moi, je vous laisse, j’ai une partie de Golf avec Johnny Depp qui m’attend. Je vous donne rendez-vous la semaine prochaine pour un nouvel article sur le blog des développeurs ultra-efficaces !


Accéder au bonus : Les fondamentaux de la POO en un programme.