Implémentation
Les classes
Chaque composant sera usuellement implémenté par un ensemble de classes. Ce découpage des composants en classes reste une étape de conception, cependant elle commence à faire intervenir des notions d'implémentation.
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
Comme pour les composants, une classe (ou une fonction) doit faire une seule chose et le faire bien. Cela facilite la lisibilité du code, son test, sa maintenance, sa réutilisation, son extension, etc.
Par exemple, une fonction calculant une somme et affichant le résultat a 2 responsabilités. Cela ne permettra pas de réutiliser le résultat ailleurs dans le code. Il est donc important de créer 2 fonctions ayant chacune sa propre responsabilité :
Autre exemple, un graphe se mettant à jour à partir d'une source de données. Il a 2 responsabilités : se dessiner et se mettre à jour. Il n'est ainsi pas possible de réutiliser (ou de tester) ce graphe sans le système de mise à jour (e.g. signaux, event, etc.). Si nos composants dépendent de ce système de synchronisation, toute modification de ce système impactera l'ensemble du projet. La synchronisation doit donc se faire à l'extérieur du graphe (DP servant), e.g. :
Un autre exemple, une classe peut faire beaucoup de choses, et ainsi avoir de nombreuses responsabilités. Pour éviter cela, on peut déléguer les différentes responsabilités dans plusieurs classes (e.g. , , etc.) qui seront utilisées par . Sa responsabilité sera alors de faire le lien (glue) entre les différents éléments du jeu. Cela réduira la taille de , rendra le code plus explicite, en facilitera la lecture, évitera certaines duplication de code, et permettra de tester les différents composants du jeu de manière indépendante.
Le fait de séparer les classes et les fonctions en composants élémentaires permettent leur réutilisation et évite la duplication de code. Imaginons une fonction , sauvegardant des données dans un fichier au format JSON. Cette fonction a 2 responsabilités : convertir les données au format JSON, puis les enregistrer dans un fichier. On peut cependant avoir plusieurs formats (JSON, XML, CSV, binaire, etc.). De même on peut avoir différents types de stockage (e.g. fichier, RAM, localStorage, indexDB, etc.). Il faudrait alors avoir F*S fonctions avec beaucoup de code dupliquées dans chacunes d'entre elles. Si on sépare cette fonction en 2, e.g. et , F+S fonctions suffisent alors pour supporter toutes les combinaisons possibles, e.g. .
Open-Close
Ce principe veut que le code permette l'ajout de nouvelles fonctionnalités, mais interdise la modification des classes/fonctions existantes. D'une part parce qu'on n'a pas toujours la main sur l'existant (e.g. bibliothèques), et d'autre part parce que toutes modifications d'une classe/fonction est source de bugs dans tout ce qui en dépend. Ce principe est ainsi nécessaire pour étendre le code tout en évitant de casser l'existant (regression), et ainsi garantissant la stabilité du code. Le but est aussi de laisser la porte aux modifications futures sans avoir à les anticiper (cf YAGNI).
Par exemple, si on souhaite pouvoir compresser nos données lors de leur enregistrement via , il nous faudra en modifier le code. Ce qui rajoutera une responsabilité à cette fonction, et en complexifiera le code. En revanche, si on a 2 fonctions et , nous n'avons pas besoin d'en modifier le code, et pouvons ajouter une simple fonction : . Le principe de responsabilité unique aide donc à respecter le principe Open-Close.
💡 Vous noterez que l'example ci-dessus peut très vite conduire à de longues lignes de code répétitives. Grâce à certains DP, vous pouvez créer une fonction e.g. , qui se chargera de tout cela pour vous.
Le premier réflexe est souvent d'utiliser l'héritage pour étendre une classe. Par exemple, des classes , , etc. héritant de . Cependant, dès que plusieurs extensions sont possibles, cela conduit très vite à une explosion du nombre de classes, e.g. , , etc. Il est ainsi souvent préférable d'utiliser une injection de dépendance, i.e. d'au lieu de définir un comportement par héritage, de le passer dans le constructeur de la classe mère (cf DP Strategy, Composite Reuse Principle (CRP) ):
Imaginons maintenant que nous souhaitions compresser le flux, le chiffrer, et/ou ajouter une somme de control. Une manière de le faire est d'utiliser un DP décorateur (cf mixins) pour définir de nouvelles méthodes, ou redéfinir des méthodes de :
💡 Le Design Pattern Composite encapsule un ensemble d'éléments. Par exemple un groupe de TP est composé d'un ensemble d'étudiants, et fournit les moyennes du groupe. De même un groupe de TD est composé d'un ensemble de groupes de TP, et une promotion est composée d'un ensemble de groupe de TD.
⚠ L'implémentation n'a pas à représenter le réel, mais les données. Ainsi, même si une voiture rouge est une voiture, elle n'a pas forcément à être implémentée par un héritage (bien au contraire).
Liskov
- Liskov : (éviter bugs) contrat avant/après (pre/post guards closes - asserts), design by contract.
Certains héritages pour spécialiser : une sorte d'héritage privé car respecte pas Liskov.
- exemples de problèmes
- polymorphisme
- spécialisation : héritage ou autres formes.
Interfaces
Les deux derniers principes sont assez simples. La ségrégation d'interface indique qu'un code doit dépendre d'une interface minimale, i.e. de ne dépendre que des fonctions dont il a besoin. Par exemple, une fonction qui lit un flux passé en paramètre doit pouvoir accepter un flux en lecture seule (i.e. qui n'a pas d'opération d'écriture).
Dans certains langages, cela permet d'exploiter le duck typing.
L'inversion des dépendances indique qu'il faut dépendre d'une interface, et non d'une implémentation, permettant ainsi de changer l'implémentation utilisée, et évitant des dépendances circulaires dans certains langages. Cela permet de coder des algorithmes génériques et réutilisables. Par exemple, une fonction lisant un flux passé en paramètre a juste besoin que l'objet respecte une certaine interface, et n'a pas besoin d'une implémentation de flux particulière.
UML
- 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. - État transition ?Creationnal DP
- prototype (clone) + type obj - spawn - builder ~> ex gamectrler : plusieurs resps (encore !) - complexListe (TODO)
Machines à État ???
Qualité du code
En général, la lisibilité et maintenabilité du code importante bien plus que tout autre chose. En effet, il vaut souvent bien mieux un code lent, facilement modifiable, qu'un code inutilement rapide et complexe.
Pour cela il est bien évidemment nécessaire de bien indenter son code, d'avoir des noms explicites, une arborescence de fichiers explicite, etc. Un code bien écrit doit pouvoir se comprendre aisément en le survolant. On doit, par exemple, pouvoir comprendre l'usage et l'utilité d'une fonction à sa signature. Aussi, les fonctions doivent rester courtes (moins d'un écran), une fonction longue étant généralement le signe d'une responsabilité multiple ou d'un manque d'abstraction.
Il existe de multiples techniques et d'idiomes pour rendre un code plus explicite. Par exemple, gérer les cas d'erreurs en premier, utiliser des tableaux associatifs (de valeurs ou de fonctions), utiliser certains patrons de conceptions, pas de après un , , ou / etc.
Les commentaires ne doivent pas être redondant avec le code, i.e. ne doit pas décrire le code. Ce type de commentaires n'apportent pas d'informations utiles et peuvent très vite se retrouver obsolètes lorsque le code est modifié sans que les commentaires ne soient mis à jour, induisant alors les lecteurs en erreur. Les commentaires doivent rester rares et expliquer les nuances non-triviales du code, par exemple la correction d'un bug avec le lien de son ticket (e.g. github issue). De manière générale, si un code a besoin de commentaires pour être compris, il est généralement préférable de le réécrire pour le rendre plus explicite.
⚠ Il ne faut pas confondre les commentaires avec la documentation du code, qui sert à indiquer aux autres développeurs l'usage et les fonctionnalités d'une interface. Il est recommandé d'utiliser pour cela des outils qui généreront automatiquement la documentation à partir du code, et d'éventuels exemples d'usages (qui peuvent aussi servir de tests).
De manière générale, il ne faut pas se répéter (DRY - Do Not Repeat Yourself). En effet, il est alors très aisé de se retrouver avec des versions dupliquées différentes au fur et à mesure des modifications successives du code. Cela est aussi généralement peu confortable de fréquemment rechercher un bout de code pour le copier/coller à différents endroits. Lorsqu'un même bout de code est répété 3 fois, cela est généralement signe qu'il faut refactorer et factoriser le code.