Implémentation : classes et objects
Les classes
Chaque composant est usuellement implémenté par un ensemble de classes. Le découpage des composants en classes reste une étape de conception, cependant elle commence à faire intervenir des notions d'implémentation.
Diagramme de classes
- diagramme de classes + objet - diagramme de classe à la fois conception et durant implémentation. - POO - Objet : état à un instant T : débug ou montrer changement d'états.Les classes ne sont pas LA solution
Les classes, l'héritage, et les méthodes ne sont pas la solution à tout. Avant de voir comment les utiliser, nous allons nous intéresser à quand les utiliser.
Classes vs structures
Les classes ont une sémantique d'entité. C'est à dire que chaque instance représente une entité différente. Ainsi, deux instances différentes seront considérées inégales (comparaison par référence). Modifier ses attributs ne modifie pas son identité. On ne copie pas une classe, on la clone. Les classes ont la responsabilité de la cohérence de leur données internes, et vont donc en contrôler l'accès.
Les structures ont une sémantique de valeur. C'est à dire que les instances ayant le même contenu sont considérées égales (comparaison par valeur). Les attributs sont usuellement en lecture seule. Le cas échéant, il n'est pas nécessaire d'en protéger l'accès, et les copies sont inutiles. L'héritage a généralement peu de sens.
| Classe | Structure | |
|---|---|---|
| Sémantique | entité | valeur |
| Comparaison | référence | valeur |
| Accès | RW | RO |
| Duplication | clone | inutile |
| Encapsulation | oui | non |
| Héritage | ok | boff |
Une entité peut représenter ses attributs par le biais d'une structure afin d'en faciliter la manipulation.
diag : User <>--> UserData.
Certaines bibliothèques génériques ne distinguent pas proprement classes et structures. Par exemple peut à la fois servir à représenter une position (valeur), ou un point (entité).
Héritage vs composition
L'héritage doit servir à spécialiser la classe mère, pas à en ajouter de nouvelles fonctionnalités. La chaîne d'héritage ne doit représenter qu'un seul axe de spécialisation conceptuel.
Par exemple, héritant de , lui même-héritant de fait intervenir 2 concepts : la nature du flux, et la manière d'encoder/décoder les données. Or dès qu'on ajoute de nouveaux variants (e.g. socket au lieu de fichier, binaire au lieu de JSON), ou de nouvelles fonctionnalités (e.g. compression, chiffrement), on se retrouve avec une explosion des combinaisons possibles (et donc des classes à créer), et des noms ridiculement longs (e.g. ).
On préfère donc souvent la composition à l'héritage, c'est à dire construire l'instance par l'assemblage de différentes briques. Par exemple, utilisera en interne un et un qui définiront respectivement la manière de lire le flux, et d'encoder/décoder les données (DP Strategy). Ces deux briques pourront alors être réutilisées dans d'autres classes (CRP - Composite Reuse Principle).
Mieux, on peut injecter les dépendances de l'extérieur (dependencies injections), i.e. via le constructeur, des méthodes, ou un setter. Ainsi, on peut se contenter d'un qui prendra en argument le flux à utiliser, réduisant le nombre de classes à créer.
Méthode vs fonction
En soit, une méthode n'est rien de plus qu'une fonction qui possède un paramètre spécial , et sont ainsi deux manières de faire la même chose. Le principal intérêt des méthodes est de pouvoir être redéfinies dans les classes filles, ainsi que d'accéder aux membres privés de l'instance.
Idéalement, on chercher à simplifier l'implémentation, et à réduire la surface des tests (donc de l'interface). Pour cela on limite les méthodes au strict minimum permettant de manipuler l'état interne de l'objet et d'en garantir la cohérence. On écrit ensuite des fonctions utilitaires (helpers) permettant d'orchestrer les méthodes de la classe sans être tenté d'accéder aux membres privés.
Il est tout à fait possible de fournir une interface plus ergonomique (API de convenance) en créant une classe de plus haut niveau regroupant les fonctions utilitaires :
Constructeur vs Factory Function
En soit une classe n'est qu'un moyen de créer un objet (instance), ce qui est tout à fait possible de faire via une fonction (DP Factory Function). Par exemple, une classe qui hérite d'une autre en se contentant d'en redéfinir le constructeur, peut être remplacée par une fonction équivalente :
Les fonctions ont l'avantage de ne pas préjuger de l'origine de l'instance retournée. Ainsi, la fonction peut créer une instance, mais aussi e.g. utiliser un cache, un registre, etc. De plus, les fonctions garantissent que le comportement de la classe mère n'est pas modifié, réduisant la surface des tests nécessaires.
Dans certains cas, avoir une classe ne fait aucun sens. Par exemple une classe est un non-sens, étant donné que l'entité peut, au court du temps, ne plus être un nouvel utilisateur. Il faut donc soit passer par une Factory Function, soit par une instance qu'on clonera lorsqu'on souhaite créer un nouvel utilisateur (DP Prototype).
SOLID
Les classes doivent respecter les principes SOLID :
- Single Responsability (SRP) : chaque classe fait une et une seule chose (voir aussi Separation of Concerns - SoC).
- Open-Closed (OCP) : une classe doit être ouverte aux extensions, mais fermée aux modifications.
- Liskov substitution (LSP) : on doit pouvoir remplacer une classe par une de ses classes filles.
- Interface segregation (ISP): une interface utilisée par un code ne doit pas avoir de méthodes inutilisées par celui-ci.
- Dependancy inversion (DIP): il faut dépendre d'interfaces, et non de classes concrètes.
Single responsability
Principe
Chaque élément (composants, classes, méthodes/fonctions) ne doit avoir qu'une seule responsabilité (mais le faire bien). Par exemple, une fonction qui calcule un résultat puis qui l'affiche, fait deux choses différentes : calculer et afficher.
Séparer ces deux responsabilités en 2 fonctions distinctes ( et ) facilite l'implémentation des fonctions et leurs tests. Mais surtout, cela permet leur réutilisation pour des usages différents, e.g. au lieu d'afficher le résultat (), le sauvegarder ().
Une fonction comportant plusieurs lignes fait bien "plusieurs choses". Par "responsabilité unique" on entend "une seule raison conceptuelle de changer".
Orchestration
Une fonction effectuant fait 2 choses, pourtant elle est considérée comme n'ayant qu'une seule responsabilité. En effet, on dit qu'elle délègue des responsabilités à et . L'orchestration est vue comme une responsabilité unique.
Ainsi, lorsqu'un élément fait trop de choses, on cherchera à en déléguer les responsabilités. Par exemple, une classe pourra déléguer l'affichage à , le modèle à , et le traitement des actions à . Chacun de ces sous-composants pouvant eux-même être redécoupés. La classe sert alors de glue code et d'orchestrateur.
L'orchestrateur alors ainsi transmettre l'intention, pas l'implémentation, i.e. définir ce qu'on veut faire, pas comment on le fait.
Externalisation de responsabilités
Il est aussi possible de sortir une responsabilité de la classe au lieu de la déléguer. Par exemple, une classe a pour responsabilité de définir la topologie d'un arbre, i.e. les liens entre les différents noeuds. En revanche, il n'est pas de sa responsabilité d'en définir le parcours.
D'autant plus qu'un même graphe peut-être parcouru via des algorithmes différents en fonction de ce qu'on recherche (e.g. parcours en profondeur, en largeur, Dijkstra, A*, etc). On va alors utiliser une fonction externe qui se chargera du parcours (DP Servant) :
Responsabilités latentes
Certaines responsabilités sont difficiles à identifier (latent responsabilities), et peuvent ainsi briser la responsabilité unique. Notamment, certains responsabilités émergent de l'interaction entre les objets (emergent responsibility).
Par exemple, un signal est "lié" à d'autres signaux, eux-même liés à d'autres encore. Lorsque le signal est déclenché (trigger), il notifie les signaux auxquels il est lié en les déclenchant à leur tour. On a ici un graphe implicite de signaux, avec un parcours implicite. Une responsabilité de parcours émerge donc de l'interaction des signaux entre eux.
Or le rôle d'un signal est d'être un signal, pas de savoir comment il se propage et se transmet. On va ainsi externaliser la propagation du signal via une classe qui en définira le comportement. Cette propagation peut être plus ou moins complexe : se faire en plusieurs phases (e.g , , ), utiliser des stratégies de batching (e.g. n'exécuter les listeners qu'une fois par frame), définir un ordre de parcours particulier, gérer les cycles, etc.
Open-Close
Principe
Le code doit être ouvert aux extensions, i.e. permettre l'ajout de nouvelles fonctionnalités, tout en étant fermé aux modifications, i.e. le code (stable) existant ne doit pas être modifié. Le code stable doit ainsi pouvoir être étendu sans être modifié.
En effet, modifier un code stable risque d'engendrer des bugs dans ce qui "marchait déjà" (regression), à la fois au sein du code modifié, mais aussi dans le code l'utilisant. Souvent même, on ne peut tout simplement pas modifier directement l'existant (e.g. bibliothèques), au risque de devoir réappliquer ses propres modifications pour chaque nouvelle version.
Modifier l'existant
"Fermé aux modifications" ne signifie pas qu'on s'interdit doctement toutes formes de modifications. Par exemple, insérer une nouvelle méthode à une API de convenance est vu comme un ajout de code, et non comme une modification.
De même, si une fonction possède un bug, il faut bien la modifier pour le corriger. On préférera alors souvent dupliquer la fonction, corriger l'erreur, puis supprimer l'ancienne version. Cela permet ainsi de pouvoir comparer les deux implémentations, de migrer progressivement le code, et de pouvoir facilement revenir en arrière en cas de problèmes. Pour cela on peut mettre en place une indirection :
Étendre l'existant
Pouvoir étendre l'existant nécessite de laisser la porte ouverte aux évolutions futures, pas pas de devoir les anticiper. Cela ne requiert pas de rendre le code générique.
Par exemple, une fonction dont la responsabilité est d'orchestrer l'impression d'une facture à partir d'un client et d'une liste de produits peut être rendue générique de sorte à supporter plusieurs formatages, types d'impressions, etc.
Or, la généricité obtenue par l'ajout de paramètres complexifie l'implémentation, l'usage, et les tests de la fonction... tout en étant inutile si cette généricité n'est jamais utilisée. Au pire, il est toujours possible d'écrire une autre fonction d'orchestration répondant à un autre usage/contexte. Ce n'est que lorsqu'on aura besoin de 3-4 versions différentes de la même orchestration, qu'on commencera à factoriser en généralisant la fonction.
Comment faire ?
Les concepts précédemment étudiés aident déjà au respect de l'OCP. La responsabilité unique, évite l'ajout de responsabilités à l'existant, donc sa modification. La composition permet la création/modifications de fonctionnalités, simplement en assemblant les briques différemment. L'injection de dépendances évite de modifier une classe lorsque ses dépendances changent. L'API de convenance évite à la fois de modifier la structure de base lorsqu'on ajoute une nouvelle fonctionnalité, mais évite aussi de modifier l'interface de haut niveau lorsqu'une fonction est convertie en méthode e.g. pour des raisons de performances.
On va aussi favoriser les registres aux (ou blocks de //). En effet, avec un , rajouter une valeur possible nécessite de modifier le code existant afin d'ajouter une clause correspondante. Avec un registre, e.g. tableau associatif, il suffit d'enregistrer un nouveau callback associé à la nouvelle valeur, ce qui peut se faire de manière décentralisée :
Une autre manière de procéder est de créer des templates, i.e. des fonctions/classes génériques dont on peut définir une certaine partie du comportement, e.g. par injection de dépendances. Pour les classes, on peut créer un algorithme générique dont certaines parties sont définies par l'implémentation (ou la surcharge) de méthodes (potentiellement abstraites). L'intérêt est alors de garantir le respect d'une structure algorithmique :
Liskov
Le polymorphisme
On doit pouvoir remplacer toute instance d'un type , par toute instance de ses sous-types, i.e. les instances héritant ou implémentant . Par exemple, une fonction acceptant un paramètre de type , doit pouvoir supporter tout sous-types de . Ainsi, une variable/paramètre de type peut prendre "plusieurs formes", c'est ce qu'on appelle le polymorphisme.
Or, la fonction doit pouvoir s'exécuter sans connaître le type réel du paramètre à l'exécution, i.e. "comme si" il était indistinguable d'un . En effet, si la fonction doit se retrouver à gérer chaque cas particulier, non seulement son code s'en retrouve complexifié, mais aussi devra être modifié à chaque nouvelle dérivée/implémentation de .
Contrats
Une interface défini un contrat, en indiquant ce qu'elle accepte en entrée, et ce qu'elle peut retourner. On parle alors de :
- pre-condition : ce qui doit être vrai au début de la fonction/méthode, i.e. dans les données d'entrée.
- post-condition : ce qui doit être vrai à la fin de la fonction/méthode, i.e. dans les données de sortie.
- invariant : ce qui doit rester toujours vrai.
Idéalement, ce contrat est explicité/garanti par un système de vérification de types (type checking), et/ou d'assertions.
Pour pouvoir être substituable à , tout sous-types de doit respecter les attentes posées par , i.e. son contrat (explicite comme implicite), i.e. ce à quoi s'attend tout utilisateur de . En effet, si le sous-type de :
- pre-condition : ne supporte pas certaines entrées, il ne saura pas traiter tout ce que l'utilisateur de lui transmettra.
- post-condition : retourne des valeurs supplémentaires, l'utilisateur de ne saura pas comment les traiter.
Ainsi, tout sous-type de doit accepter en entrée tout ce qu'un accepte, et ne retourner que ce qu'un peut retourner :
- pre-condition : au moins .
- post-condition : au plus .
- invariant : toujours vrai.
Faux héritage
Certaines spécialisations ne correspondent pas systématiquement à un sous-type. Par exemple, on a appris qu'un carré était un cas particulier d'un rectangle. On serait ainsi tenté de faire hériter de . Or, si cela ne pose pas de problèmes pour des structures (i.e. RO), pour des entités (i.e. RW), 2 problèmes se posent :
- un utilisateur de ne s'attend pas à ce que la modification de la largeur modifie aussi sa longueur.
- un dont la largeur devient égale à sa longueur ne se transforme pas en (et inversement).
Ainsi, il faut considérer soit que et sont deux entités distinctes avec leurs propres comportements, soit qu'un carré n'est pas un type, mais une propriété de vérifiée lorsque sa largeur égale sa longueur.
L'héritage doit ainsi être utilisé avec parcimonie, et ne doit pas être servir à juste réutiliser des comportements. On préférera souvent la composition qui permet de choisir des sous-parties de comportements à hériter, remplacer, voire supprimer. On ne défini alors plus un sous-type mais un type différent.
L'exception
Nous avons précédemment affirmé qu'une fonction doit pouvoir s'exécuter sans connaître le type réel de l'instance qui lui est transmise. Cela n'est cependant pas toujours vrai, notamment dans le cas où une méthode redéfinissable a été retirée de l'interface.
Par exemple, la classe pouvait définir une méthode que les classes et redéfinissaient. Or, il n'est pas de la responsabilité d'une forme de savoir se dessiner. Le problème est alors de pouvoir ré-associer les fonctions et avec respectivement et .
Une solution, bien que peu idéale, est alors de passer par un registre :
Ségrégation des interfaces
Un code ne doit dépendre que de ce dont il a besoin, i.e. de l'interface minimale permettant de répondre à ses besoins. Par exemple, une fonction affichant un rectangle n'a pas à connaître (et donc de dépendre) des méthodes permettant de modifier le rectangle.
Cela permet à la fois d'identifier de manière précise les dépendances d'un code, d'apporter des garanties, ainsi que de rendre le code plus générique. En effet, une fonction qui dépend une interface sans mutateurs, indique (et garantie) qu'elle ne modifiera pas l'objet, et aura des contraintes moindres quant à ce qu'elle accepte.
Par exemple, une fonction acceptant une en lecture seule pourra aussi accepter une car partagent la même interface en lecture, même si elles ont des interfaces incompatibles en écriture.
Certains langages supportent le duck typing, i.e. tout objet est compatible avec une interface, sans avoir besoin de l'implémenter ou de l'étendre, tant qu'il en respecte les contraintes.
If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
Principe de moindre connaissance
Un principe connexe à la ségrégation des interfaces est le principe de moindre connaissance : le code doit avoir une connaissance minimale des choses.
Par exemple une fonction n'a pas à savoir comment un produit stocke son prix brut, ni de la manière dont le taux de TVA est récupéré. Le fait de ne pas dépendre d'une interface permet à la fonction d'être réutilisée directement, ou avec d'autres structures.
Rien n'empêche par la suite d'ajouter une fonction/méthode de convenance préparant les paramètres, puis appelant la fonction .
Inversion des dépendances
Le code doit dépendre d'une abstraction stable agnostique de l'implémentation. L'abstraction doit ainsi communiquer l'intention sans dépendre de l'implémentation. Cela permet à la fois de protéger les utilisateurs de potentiels changements dans l'implémentation, mais aussi de pouvoir indifféremment utiliser différentes implémentations.
Cela ne signifie pas que tout doit dépendre d'une interface. Une abstraction peut aussi être une classe abstraite, voire une classe concrète, qu'il est possible de transformer ensuite en interface lorsque le besoin émerge.
Par exemple, une fonction dépendant d'une abstraction est générique : elle peut être (re)utilisée avec toutes implémentations de l'abstraction. Ainsi, si elle prend un flux, elle pourra accepter tout type de flux respectant la même interface, indépendamment son implémentation.
Contrairement à ce qui est affirmé par endroits, l'inversion des dépendances ne dit rien quant au composant qui doit être propriétaire de l'interface (composant utilisateur, composant fournissant l'implémentation, ou autre ?). Idéalement, on la placera dans un composant abstrait qui sera implémentée d'un côté, et utilisée de l'autre.
Dépendre d'interfaces peut aussi éviter des dépendances circulaires dans certains langages.
Qualité du code
Le code doit être facilement modifiable tout en garantissant sa validité. Nous avons vu dans le cours d'Automatisation et Tests en Programmation comment garantir la validité d'un code à travers les tests. Nous avons vu dans ce cours comment construire une architecture évolutive. Cela n'est cependant pas suffisant.
Lisibilité du code
Pour être facilement maintenable, un code doit être facilement lisible, par soit même, ou par un tiers. Un code bien écrit doit ainsi pouvoir facilement se comprendre en le survolant. L'arborescence des fichiers doit être explicite, matérialisant les frontières de l'architecture, et permettant aisément de retrouver un code donné.
Les noms doivent être explicites et dévoiler l'intention. L'interface et les signatures des fonctions/méthodes doivent suffire à en comprendre la fonction et l'usage. Les fonctions doivent rester relativement courtes (généralement un écran) : une fonction trop longue étant souvent le signe de multiples responsabilités ou d'un manque d'abstraction.
Le code en lui-même doit être facile à lire :
- le code doit être indenté correctement, les structures répétées mises en évidence par leur alignement.
- gérer les cas courts/d'erreurs en premier, sans mettre de après un , /, ou .
- utiliser le chaînage de méthodes lorsque pertinent, en alignant les appels, e.g. :
Glue code
Nous avons évoqué le glue code, comme le code permettant d'initialiser le projet. Ce code a tendance à s'agrandir au fur et à mesure des ajouts de fonctionnalités, devenant très rapidement un fouillis où tout est entre-mêlé (spaghetti code).
Pour éviter cela, le glue code doit se contenter d'expliciter des intentions, implémentées dans des fonctions ad hoc, e.g. :
Cet exemple n'est pas idéal, car tout changement de la configuration d'une fonctionnalité requiert potentiellement de modifier . On pourrait alors créer une structure déclarant son initialisation, sa configuration, ses dépendances, ses dépendants, etc.
Toutefois, cela ajoute une couche de complexité pour un gain discutable. En effet, le glue code n'a pas besoin d'être stable, réutilisable, ou testable.
Tout n'est pas un clou
Quand on a un marteau, tout ressemble à un clou.
Un outil peut fonctionner pour certains usages/besoins sans être pour autant idéal. Par exemple, lorsqu'on a un système de signaux, on est tenté de l'utiliser de partout, contaminant l'ensemble du code, et complexifiant certaines opérations pourtant triviales à l'origine.
Il est ainsi important de bien connaître les limites de ses outils, et le cadre dans lequel les utiliser. Les signaux peuvent ainsi être très utiles lorsqu'on a des valeurs évaluées paresseusement, avec un système de synchronisation de valeurs. Cependant, il est très difficile de conserver une évaluation paresseuse performante lorsqu'on synchronise une sous-partie d'une valeur (risque de boucle dans l'évaluation).
Il est ainsi nécessaire de s'imposer des règles, un cadre, pour l'utilisation de ces outils, e.g. :
- seules les entités sont réactives, les structures étant immuables.
- utiliser l'évaluation paresseuse lorsque le consommateur peut sauter des valeurs coûteuses à calculer.
- ne pas évaluer paresseusement des entités complexes si des sous-parties peuvent être synchronisées.
Connaître les outils
Quand n'on a qu'un marteau, tout est un clou.
Connaître (et maîtriser) un maximum d'outils vous permettra de choisir le plus pertinent pour un problème donné. Il est ainsi important de connaître les patrons (patterns) fréquent de son domaine et du langage utilisé.
Dans le cadre de ce cours, nous avons évoqué un grand nombre de concepts et de patrons. Nous allons ainsi finir cette partie en évoquant quelques autres. Le principal est d'en retenir l'existence afin de pouvoir les retrouver par la suite.
Historique
Il est fréquent d'avoir besoin d'un historique, que ce soit pour des opérations d'annulations/restaurations, ou pour sauvegarder l'état actuel en vue d'une restoration future. Bien évidemment, si l'état est déjà persistant (e.g. stocké dans une BDD), il est possible d'utiliser des outils dédiés existants.
Dans le cas où l'état est (tout ou partie) volatile, on peut alors soit effectuer un enregistrement de l'état (sauvegarde complète), soit des différentes actions ayant conduit à cet état (sauvegarde incrémentale). Le premier s'effectue en demandant à une classe d'exporter une copie de son état interne brut, i.e. sans encodage (DP Memento). Le second s'effectue en stockant chaque action sous la forme d'un objet permettant de l'exécuter et de le révoquer (DP Command).
Le DP Command permet aussi d'implémenter les transactions. Il suffit alors d'enregistrer les actions effectuées depuis le début de la transaction, puis, de les valider ou les rejeter lorsque la transaction est terminée.
Structure composite
Il est fréquent d'avoir une hiérarchie d'objets (e.g. menu, dossiers, DOM, etc). Dans ce cas, il peut être intéressant de voir la hiérarchie comme un arbre, et chaque objet comme un noeud. Une interface permet alors d'uniformiser les traitements effectués sur les noeuds, indépendamment du fait qu'il soit terminal ou non (DP Composite). Les noeuds terminaux (feuilles) sont considérés comme des noeuds normaux, mais dont la liste des fils est invariablement vide.
Les requêtes envoyées à un noeud peuvent alors être :
- transmis aux noeuds fils, et leur résultat agrégé (DP Composite).
- traité par le noeud ou transmis récursivement à l'un noeud fils (ou parent), (DP chain of responsability).
La chaîne de responsabilité est notamment utilisée pour identifier l'élément graphique ciblé par un clic. L'événement souris est alors envoyé au noeud racine qui le redirige récursivement vers l'enfant ciblé.
Composition
Une manière d'effectue une composition est d'utiliser le DP Decorator afin d'ajouter des responsabilités à une classe, une fonction, ou à une instance. On peut la retrouver sous 3 formes différentes :
L'enchaînement de décorateurs peut en revanche devenir très vite très lourd :
Pour éviter cela, le DP Builder permet de construire un objet pas à pas, e.g. une structure immuable, un objet décoré, etc. :
Une autre solution est d'utiliser un DP Factory Function pour construire un objet à partir d'une configuration :
Si la configuration représente le type de l'objet construit, on parle alors de DP Type Object.