Implémentation : classes et objects
Les classes
Chaque composant est usuellement implémenté par un ensemble de classes, qu'on représente alors par un diagramme de classes (UML).
Le découpage des composants en classes reste une étape de conception, cependant elle commence à faire intervenir des notions d'implémentation.
Les types
Un type est représenté par un rectangle subdivisé en 3 régions :
- son nom (en gras).
- ses attributs.
- ses méthodes.
Le nom du type peut être surmonté de son stéréotype (e.g. interface, abstract). En l'absence de stéréotype explicite, le type est une classe.
Les paramètres templates (ou génériques) sont indiqués par un rectangle en haut à droite.
Les membres
Il est possible d'indiquer la visibilité de chaque membres (attributs et méthodes) par le biais d'un symbole à gauche de son nom :
- : publique.
- : paquet.
- : protégé.
- : privé.
D'autres propriétés du membres peuvent aussi être précisées :
- statique : en souligné.
- abstrait : en italique.
- calculé :
Les variables
Les variables (attributs, et paramètres) peuvent préciser leur :
- type :
- multiplicité/cardinalité :
- valeur par défaut :
- contraintes :
Bien qu’UML dispose de sa propre notation pour les types, je vous recommande d’utiliser directement les types du langage que vous aurez choisi.
Les liens
Le diagramme de classe distingue 6 types de liens :
- Héritage
- Association
- Réalisation (pour les interfaces).
- Agrégation
- Dépendance
- Composition (appartient à la classe).
La relation de composition est plus forte que l'agrégation :
- le composant ne peut appartenir qu'à un seul composé (et ne peut en changer).
- lors de la destruction du composé, ses composants sont aussi détruits.
L'agrégation et la composition sont toutes deux des associations.
Les associations peuvent être réflexives, faisant le lien entre la classe et elle-même.
Lecture de l'association
La flèche donne le "sens" (navigabilité) de l'association : la classe à l'origine connaît l'association, tandis que la classe pointée en ignore l'existence. Si les deux classes connaissent l'association, on utilise alors une simple ligne plutôt qu'une flèche.
L'association peut également porter un nom, permettant d'en préciser la signification. Par exemple, une association entre une "Personne" et un "Livre" peut être nommée "a écrit", "a lu", "recommande", "possède", "emprunte", "a réservé", selon la relation qu'on souhaite exprimer. Une flèche à côté du nom permet d'en préciser le sens de lecture.
Les dépendances quant à elles ne sont généralement pas nommées, mais ont un stéréotype.
Rôles
À chaque extrémité de l'association, il est possible d'indiquer, à côté de la classe, le rôle qu'elle joue dans l'association. Généralement, il sera alors utilisé comme nom de l'attribut représentant l'association dans la classe opposée.
Bien qu'il ne soit pas nécessaire de représenter l'attribut dans la classe, vous pouvez tout de même en préciser la visibilité, la multiplicité, et les contraintes.
Attention au sens de lecture. Une extrémité décrit :
- le rôle joué par la classe adjacente.
- l'attribut correspondant dans la classe opposée.
Indices
Lorsque les associations sont stockées sous la forme d'un tableau associatif, on peut préciser, dans un encart, le nom et le type de la clef. La multiplicité indique alors le nombre d'instances associées à chaque clef.
Les autres diagrammes UML
Les notations du diagramme de composants peuvent aussi être utilisées dans le diagramme de classe.
Diagramme d'Objet
Le diagramme d'objet est un diagramme de classe dans lequel on représente des instances, permettant de représenter l'état d'un système à un instant T. Il peut alors être utilisé pour documenter e.g. un bug (e.g. l'état permettant de reproduire le bug, l'état dans lequel l'application se trouve après le bug, etc), ou une opération (état avant et après l'opération).
Les objets peuvent être inclus dans un diagramme de classe, et reliés à une classe par le biais d'un lien de dépendance .
Diagramme de structures composites
Le diagramme de structures composites permet de représenter la structure d'un système. Le diagramme de déploiement, de composant, ou de classe lui sera généralement préféré. Il peut toutefois être utilisé pour représenter un système physique et ses composants matériels (e.g. dans l'embarqué).
Diagramme de profils
Le diagramme de profils permet d'étendre UML, e.g. en déclarant de nouveaux stéréotypes. En pratique ce diagramme n'est jamais utilisé. Décrire textuellement les conventions ajoutées est généralement bien plus lisible et compréhensible.
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, permet leur réutilisation pour des usages différents, e.g. sauvegarder au lieu d'afficher le résultat (i.e. au lieu de ).
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 conceptuellement différentes, 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 unique responsabilité.
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é latentes
Certaines responsabilités sont difficiles à identifier (latent responsabilities), et peuvent ainsi enfreindre le principe de responsabilité unique.
Par exemple une méthode semble ne faire qu'une seule chose (mettre à jour), mais recouvre potentiellement plusieurs responsabilités qu'on peut séparer en plusieurs méthodes :
- : planifier la mise à jour (peut déléguer à un scheduler).
- : orchestrer la mise à jour (notamment si l'objet est une composition de différentes parties).
- : actions spécifiques à la classe (appelé par ).
Responsabilités émergentes
Certains responsabilités latentes peuvent aussi émergent de l'interaction entre objets (emergent responsibility).
Par exemple, un objet émet une notification lorsqu'il est déclenché (trigger). Cette notification peut elle-même déclencher d'autres signaux. L'interaction entre ces différents signaux fait implicitement émerger un graphe de propagation, qui est parcouru lorsqu'un signal est déclenché. Une nouvelle responsabilité émerge ainsi de l'interaction entre les signaux.
Or la responsabilité d'un est de notifier lorsqu'il est déclenché, pas de savoir comment il se propage. On va alors externaliser la propagation du signal via une classe qui en définira explicitement 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. ne notifier 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 existant et stable ne doit pas être modifié). Le code stable doit ainsi pouvoir être étendu sans être modifié.
En effet, modifier un code stable risque d'introduire 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 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 nouvelle 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 conditions ( ou blocks de //). En effet, avec des conditions, toute nouvelle valeur possible nécessite de modifier le code afin d'ajouter le cas correspondant. Avec un registre (e.g. un tableau associatif), il suffit d'associer un nouveau callback à la 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 (re)définir certaines parties 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 de méthodes abstraites (ou redéfinitions de méthodes concrètes). L'intérêt est alors de garantir le respect d'une structure algorithmique :
Liskov
Le polymorphisme
Toute variable (ou constante) de type peut, en réalité, être une instance de n'importe quel sous-type de (i.e. héritant ou implémentant ). Par exemple, une fonction qui prend en paramètre un argument de type peut recevoir une instance de l'un de ses sous-types. Ainsi une même variable (ou constante) peut faire référence à des instances de types concrets différents, et donc prendre "plusieurs formes". C'est ce qu'on appelle le polymorphisme.
Or, si le code se retrouve à effectuer un traitement particulier en fonction du type concret de la variable (ou constante), non seulement il s'en retrouve complexifié, mais devra également être adapté pour chaque nouvelle dérivée (ou implémentation). Ainsi le code ne devrait pas avoir à savoir le type concret de la variable (ou constante).
Contrats
Une interface défini un contrat, 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), ce à quoi s'attendra 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 sait qu'un carré est un rectangle particulier. 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. La composition ne définira alors pas un sous-type, mais un nouveau 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 moins de contraintes quant à ce qu'elle accepte.
Par exemple, une fonction acceptant une en lecture seule pourra ainsi accepter e.g. une si son interface est compatible en lecture, et ce même si elle ne l'est pas 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.
Découper les interfaces en capacités
Pour cela, on ne résonne plus en termes de types (d'objets), mais en termes de capacités. Par exemple l'interface d'un peut être décomposée en 3 capacités :
- : le déclencher.
- : l'observer.
- : vider ses observateurs.
Une capacité peut regrouper plusieurs méthodes. Par exemple, permettra généralement d'observer le signal, mais aussi de vérifier et d'arrêter cette observation (soit 3 méthodes).
Une fonction (ou classe) n'a ainsi pas réellement besoin d'un , mais d'un objet fournissant les capacités qu'elle exploite réellement (e.g. et ). Cela peut alors être l’occasion de se demander si la fonction (ou classe) doit réellement dépendre de cette capacité, ou s’il est possible de la rendre indépendante.
Par exemple, une classe utilisant un , peut être modifiée pour dépendre d'un callback à la place. Elle devient ainsi indépendante du système de signal, mais est toujours compatible avec celui-ci : il suffit de remplacer le par un callback déclenchant le signal.
Il est aussi possible de créer une classe agnostique, centrée sur les concepts métiers et indépendante des autres interfaces. Une autre classe peut alors l’intégrer au système en lui ajoutant des comportements, e.g. en déclenchant le signal après chaque modification.
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.
DON'T
DO
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 ensuite possible de transformer en interface lorsque le besoin émerge.
Par exemple, une fonction prenant en argument un flux pourra être appelée avec tout type de flux, tant qu'ils en respectent l'interface. Si l'implémentation d'un flux change, son usage restera inchangé.
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.
Niveaux d'indépendance
Nous pouvons distinguer plusieurs niveau d'indépendances :
- Dépendance à une implémentation : le code utilise directement l'implémentation concrète.
- Dépendance à une abstraction (DIP): le code est indépendant de l'implémentation, mais a besoin de l'importer.
- Dépendance injectée : la dépendance n'est pas importée par le code, mais reste nécessaire à l'exécution.
- Indépendance totale : le code est exécuté sans la dépendance, la fonctionnalité peut être ajoutée e.g. par décoration.
L'inversion des dépendances se situe ainsi au niveau 1. L'injection de dépendances (ou de hooks) permet d'atteindre le niveau 2 (ou 3), cependant elle nécessite de spécifier les dépendances à chaque usage. Pour éviter cela, on peut utiliser des factory et/ou des fonctions de convenances :
Les hooks permettent de dépendre d'une interface qu'on défini soit-même au lieu de dépendre d'interfaces externes.
Détails et politique
On peut séparer le code en 2 catégories :
- les détails : implémente l'abstraction, détermine comment on fait.
- la politique : utilise l'abstraction, détermine ce qu'on fait. Code généralement trivial, donne l'intention.
Par exemple, la fonction qui écoute une cible et exécute le callback une première fois est une politique :
Dépendre de la fonction n'est pas une infraction au DIP. Elle ne dépend pas d'une implémentation concrète, mais est construite par-dessus l'abstraction, qu'elle enrichit.
Transformer un maximum de détails en politiques permet de minimiser le contrat, et, par conséquent :
- le volume de code à implémenter.
- la part susceptible de changer.
Contrats publiques et internes
On peut distinguer la partie publique, de la partie interne d'un contrat :
- partie publique : ce que l'extérieur du module peut utiliser.
- partie interne : ce que les politiques internes au module utilisent, et que l'implémentation doit donc respecter.
L'extérieur du module va dépendre de la partie publique du module, mais aussi de sa partie interne, car requis par les politiques internes. Ainsi, pour respecter le DIP, la partie interne du contrat doit aussi être une abstraction.
On souhaite ainsi exposer une interface publique qui cache sa partie interne, tout en évitant des conversions dangereuses. Pour cela, on pourra cacher l'interface interne derrière une propriété privée (ou un symbole privé en TS), empêchant son accès par l'extérieur :
Vous remarquerez que la fonction n'implémente pas directement l'écoute, mais se contente de rediriger la requête. Elle ne dépend que de l'abstraction, et reste ainsi dans la partie "politique". Elle peut donc être utilisée avec n’importe quelle implémentation, sans avoir à recourir à une fonction spécifique à chaque implémentation.
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.