Globalement, tous les langages de programmation peuvent être classés en deux paradigmes :
Programmation impérative / orientée objet - Suit une séquence d'instructions exécutées ligne par ligne.
Programmation déclarative / fonctionnelle - Non séquentielle mais se préoccupe davantage de l'objectif du programme. L'ensemble du programme est comme une fonction qui a en outre des sous-fonctions, chacune effectuant une certaine tâche.
En tant que développeur junior, je réalise à la dure (lire : je regardais nerveusement des milliers de lignes de code) qu'il ne s'agit pas seulement d'écrire du code fonctionnel, mais aussi du code sémantiquement simple et flexible.
Bien qu'il existe plusieurs meilleures pratiques pour écrire du code propre dans les deux paradigmes, je vais parler des principes de conception SOLID concernant le paradigme de programmation orienté objet.
S — Responsabilité unique
O—Principe ouvert-fermé
L — Principe de substitution de Liskov
I — Ségrégation d'interface
D — Inversion de dépendance
La principale raison de toute difficulté à saisir ces concepts n'est pas parce que leurs profondeurs techniques sont insondables, mais parce qu'il s'agit de lignes directrices abstraites généralisées pour l'écriture de code propre dans la programmation orientée objet. Examinons quelques diagrammes de classes de haut niveau pour faire comprendre ces concepts.
Ce ne sont pas des diagrammes de classes précis, mais des plans de base pour aider à comprendre quelles méthodes sont présentes dans une classe.
Prenons un exemple de café tout au long de l'article.
Une classe ne devrait avoir qu'une seule raison de changer
Considérez cette classe qui gère les commandes en ligne reçues par le café.
Qu'est ce qui ne va pas avec ça?
Cette classe unique est responsable de plusieurs fonctions. Que faire si vous devez ajouter d'autres modes de paiement ? Que se passe-t-il si vous avez plusieurs façons d'envoyer une confirmation ? Changer la logique de paiement dans la classe qui se charge de traiter les commandes n'est pas de très bonne conception. Cela conduit à un code hautement non flexible.
Une meilleure façon serait de séparer ces fonctionnalités spécifiques en classes concrètes et d'en appeler une instance, comme indiqué dans l'illustration ci-dessous.
Les entités doivent être ouvertes pour extension mais fermées pour modification.
Au café, vous devez choisir les condiments pour votre café parmi une liste d'options et il y a une classe qui s'en occupe.
Le café a décidé d'ajouter un nouveau condiment, le beurre. Remarquez comment le prix changera en fonction du condiment sélectionné et la logique de calcul du prix se trouve dans la classe Café. Non seulement nous devons ajouter une nouvelle classe de condiments à chaque fois, ce qui crée d'éventuels changements de code dans la classe principale , mais également gérer la logique différemment à chaque fois.
Une meilleure façon serait de créer une interface de condiments qui peut à son tour avoir des classes enfants qui remplacent ses méthodes. Et la classe principale peut simplement utiliser l'interface des condiments pour passer les paramètres et obtenir la quantité et le prix de chaque commande.
Cela a deux avantages :
1. Vous pouvez modifier dynamiquement votre commande pour avoir différents ou même plusieurs condiments (le café avec du moka et du chocolat semble paradisiaque).
2. La classe Condiments va avoir une relation has-a avec la classe Coffee, plutôt que is-a. Ainsi, votre café peut avoir un moka/beurre/lait plutôt que votre café est un type de café moka/beurre/lait.
Chaque sous-classe ou classe dérivée doit être substituable à sa classe de base ou parent.
Cela signifie que la sous-classe doit être directement capable de remplacer la classe mère ; il doit avoir la même fonctionnalité. J'ai eu du mal à comprendre celui-ci parce que cela ressemble à une formule mathématique complexe. Mais je vais essayer de le préciser dans cet article.
Considérez le personnel du café. Il y a des baristas, des gérants et des serveurs. Ils ont tous des fonctionnalités similaires.
Par conséquent, nous pouvons créer une classe Staff de base avec name, position, getName, getPostion, takeOrder(), serve().
Chacune des classes concrètes, Waiter, Barista et Manager peut en dériver et remplacer les mêmes méthodes pour les implémenter selon les besoins du poste.
Dans cet exemple, le principe de substitution de Liskov (LSP) est utilisé en garantissant que toute classe dérivée de Staff peut être utilisée de manière interchangeable avec la classe de base Staff sans affecter l'exactitude du code.
Par exemple, la classe Waiter étend la classe Staff et remplace les méthodes takeOrder et serveOrder pour inclure des fonctionnalités supplémentaires spécifiques au rôle d'un serveur. Cependant, plus important encore, malgré les différences de fonctionnalité, tout code qui attend un objet de la classe Staff peut également fonctionner correctement avec un objet de la classe Waiter.
public class Cafe { public void serveCustomer (Staff staff) { staff.takeOrder(); staff.serveOrder(); } } public class Main { public static void main (String[] args) { Cafe cafe = new Cafe(); Staff staff1 = new Staff( "John" , "Staff" ); Waiter waiter1 = new Waiter( "Jane" ); restaurant.serveCustomer(staff1); // Works correctly with Staff object
restaurant.serveCustomer(waiter1); // Works correctly with Waiter object
} }
Ici la méthode serveCustomer() dans la classe Cafe, prend un objet Staff comme paramètre. La méthode serveCustomer() appelle les méthodes takeOrder() et serveOrder() de l'objet Staff pour servir le client.
Dans la classe Main, nous créons un objet Staff et un objet Waiter. Nous appelons ensuite la méthode serveCustomer() de la classe Cafe deux fois - une fois avec l'objet Staff et une fois avec l'objet Waiter.
Étant donné que la classe Waiter est dérivée de la classe Staff, tout code qui attend un objet de la classe Staff peut également fonctionner correctement avec un objet de la classe Waiter. Dans ce cas, la méthode serveCustomer() de la classe Cafe fonctionne correctement avec l'objet Staff et l'objet Waiter, même si l'objet Waiter possède des fonctionnalités supplémentaires spécifiques au rôle d'un serveur.
Les classes ne devraient pas être obligées de dépendre de méthodes qu'elles n'utilisent pas.
Il y a donc ce distributeur automatique très polyvalent au café qui peut distribuer du café, du thé, des collations et des sodas.
Qu'est-ce qui va pas avec ça? Rien techniquement. Si vous devez implémenter l'interface pour l'une des fonctions telles que la distribution de café, vous devez également implémenter d'autres méthodes destinées au thé, aux sodas et aux collations. Ceci est inutile et ces fonctions ne sont pas liées les unes aux autres fonctionnalités. Chacune de ces fonctions a très moins de cohésion entre elles.
Qu'est-ce que la cohésion ? C'est un facteur qui détermine à quel point les méthodes d'une interface sont liées les unes aux autres.
Et dans le cas du distributeur automatique, les méthodes ne sont guère interdépendantes. Nous pouvons séparer les méthodes car elles ont une très faible cohésion.
Désormais, toute interface destinée à implémenter une chose ne doit implémenter que takeMoney () qui est commun à toutes les fonctions. Cela sépare les fonctions non liées dans une interface, évitant ainsi d'implémenter avec force des fonctions non liées dans une interface.
Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau. Les détails doivent dépendre des abstractions.
Considérez le climatiseur (refroidisseur) au café. Et si vous êtes comme moi, il fait toujours froid là-dedans. Regardons la télécommande et les classes AC.
Ici, remoteControl est le module de haut niveau qui dépend de AC, le composant de bas niveau. Si j'obtiens un vote, je voudrais aussi un radiateur :P Alors pour avoir une régulation générique de la température, plutôt que du refroidisseur, découplons télécommande et contrôle de la température. Mais la classe remoteControl est étroitement couplée à AC, qui est une implémentation concrète. Pour découpler la dépendance, nous pouvons créer une interface qui a juste des fonctions d'augmentationTemp() et de diminutionTemp() dans une plage de, disons, 45-65 F.
Comme vous pouvez le voir, les modules de haut niveau et de bas niveau dépendent d'une interface qui résume la fonctionnalité d'augmentation ou de diminution de la température.
La classe de béton, AC, met en œuvre les méthodes avec la plage de température applicable.
Maintenant, je peux probablement obtenir cet appareil de chauffage que je veux en mettant en œuvre différentes plages de température dans une classe de béton différente appelée appareil de chauffage.
Le module de haut niveau, remoteControl, n'a qu'à se soucier d'appeler la bonne méthode pendant l'exécution.